diff --git a/.github/workflows/GoBuilder.yml b/.github/workflows/GoBuilder.yml index c2439fc9..c6595e06 100644 --- a/.github/workflows/GoBuilder.yml +++ b/.github/workflows/GoBuilder.yml @@ -22,12 +22,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.21" - - - name: Install dependencies - run: | - go mod tidy - go get . + go-version-file: go.mod - name: Build run: | @@ -49,12 +44,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.21" - - - name: Install dependencies - run: | - go mod tidy - go get . + go-version-file: go.mod - name: Build Windows Executable run: | @@ -256,12 +246,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.21" - - - name: Install dependencies - run: | - go mod tidy - go get . + go-version-file: go.mod - name: Extract version id: extract_version_macos @@ -550,12 +535,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.21" - - - name: Install dependencies - run: | - go mod tidy - go get . + go-version-file: go.mod - name: Build Android Binary run: | diff --git a/.github/workflows/gofmt.yml b/.github/workflows/gofmt.yml index 9f23130b..5f36b068 100644 --- a/.github/workflows/gofmt.yml +++ b/.github/workflows/gofmt.yml @@ -1,18 +1,18 @@ -name: Go Format +name: Go Quality on: pull_request: branches: ["**"] jobs: - gofmt: + quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version-file: go.mod - name: Check gofmt run: | UNFORMATTED=$(gofmt -l $(git ls-files '*.go')) @@ -21,3 +21,9 @@ jobs: echo "$UNFORMATTED" >&2 exit 1 fi + - name: Check module files + run: go mod tidy -diff + - name: Run go vet + run: go vet ./... + - name: Run tests + run: go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47d0004c..6547a915 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version-file: go.mod - name: Extract version from debug.go id: extract_version diff --git a/README.md b/README.md index 79ca6ed6..b5a622f9 100644 --- a/README.md +++ b/README.md @@ -19,45 +19,24 @@ ## Overview -**cli-top** is an easy-to-use tool for VIT students that helps them quickly access important information from the VTOP student portal. Whether it's checking grades, viewing the timetable, or handling assignments, cli-top makes it simple to get what you need. - - +**cli-top** gives VIT students quick terminal access to VTOP records, schedules, course resources, and campus services. ## Features -- **Login**: Secure login to the VTOP portal -- **Mark View**: Check your marks for various courses -- **Digital Assignment**: Manage your digital assignment submissions -- **Course Page**: Access course materials and updates -- **Academic Calendar**: Keep track of important academic dates -- **Holiday List**: See upcoming holidays that actually cancel your classes -- **Today Planner**: See today's classes and whether attendance gives you room to skip any -- **Tomorrow Planner**: See tomorrow's classes and whether attendance gives you room to skip any -- **Day After Planner**: See the day after tomorrow's classes and whether attendance gives you room to skip any -- **Exam Schedule**: View upcoming exam schedules -- **Attendance Calculator**: Calculate your attendance percentage -- **Time Table**: Easily view your class schedule -- **Class Messages**: Stay updated with class announcements -- **Leave Status**: Check the status of your leave applications -- **Nightslip**: Monitor hostel nightslip requests and submit a new one when none is pending -- **Library Dues**: Stay on top of library dues -- **Receipts**: Access fee receipts and payment history -- **Grade View**: Review your grades and academic performance -- **Student Profile**: View personal details -- **Hostel Info**: Check hostel details -- **CGPA View**: Track your cumulative GPA over semesters -- **Syllabus**: Easily download syllabus files -- **Facility**: View hostel facilities -- **Logout**: Securely logout from the CLI - +- **Academic records:** attendance, marks, grades, CGPA, exam schedules, and profile details +- **Planning:** timetable, academic calendar, holidays, and attendance-aware plans for today, tomorrow, and the day after +- **Course tools:** course allocation, current and archived course materials, syllabi, and digital-assignment status and question papers +- **Campus services:** events, facilities, hostel details, leave, nightslips, library dues, and receipts +- **Communication:** class messages and announcements +- **Local account management:** encrypted credential storage and logout +See the [command reference](docs/FEATURES.md) for every command, alias, and flag. ## Tech Stack - **GoLang** : Core programming language - **Cobra** : Go library for creating the terminal CLI - ## Installation To install **cli-top**, you can download the binary directly from [cli-top.acmvit.in](https://cli-top.acmvit.in/). @@ -69,6 +48,7 @@ To install **cli-top**, you can download the binary directly from [cli-top.acmvi 2. **Run the Binary:** After downloading, navigate to the folder where the binary is saved and run it from your terminal: + ```bash ./cli-top ``` @@ -83,40 +63,41 @@ CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o cli-top . Using `-trimpath` removes local file paths from the executable and, together with `-ldflags "-s -w"`, helps reduce binary size. - ## Usage -After installation, you can access various features of **cli-top** by running specific commands: - -- **Login to VTOP:** +Store your VTOP credentials locally. Missing values are prompted for interactively: ```bash -./cli-top login +cli-top login [--username USERNAME] [--password PASSWORD] ``` -- **View Marks:** +`login` encrypts and stores the password; the first portal-backed command performs the VTOP authentication. + +Run a feature command: ```bash -./cli-top marks -``` -- **Calculate Attendance:** -```bash -./cli-top attendance -``` -- **Check Tomorrow's Skip Leverage:** -```bash -./cli-top tomorrow +cli-top marks +cli-top attendance --semester 1 +cli-top exams --semester 1 +cli-top today +cli-top msg # alias: class-messages ``` -- **Check Today's Skip Leverage:** + +List all public commands or inspect one command's flags: + ```bash -./cli-top today +cli-top --help +cli-top attendance --help ``` -- For a full list of commands and features of cli-top, you can run: + +## Testing ```bash -./cli-top help +go test ./... +go test -race ./... ``` +Cross-package workflow and executable tests live in [`tests/`](tests/). Package-local `_test.go` files are reserved for white-box unit tests that exercise unexported implementation details. ## Project Management @@ -125,24 +106,21 @@ After installation, you can access various features of **cli-top** by running sp - Push to the "dev" branch for testing and compatibility checks - Main and dev branch pushes require approval from designated maintainers - ## Authors - [Saharsh Bhansali](https://github.com/saharshbhansali) - [Manav Muthanna](https://github.com/ManavMuthanna) - [Sarthak Gupta](https://github.com/gptsarthak) - ## Maintainers - [Garv Jain](https://github.com/notcoolgarv) - [Tanmay Paturu](https://github.com/Tintedfireglass) - [Shambhavi Paygude](https://github.com/shambhavipaygude) -- [Harshit Vootukuri](https://github.com/hvoot36) +- [Harshit Vootukuri](https://github.com/btcry) - [Adheesh Garg](https://github.com/qwerty-dvorak) - [Ishaan S](https://github.com/theg1239) - ## Contributors - [Prateek Srivastava](https://github.com/prateek-srivastava001) @@ -162,7 +140,6 @@ After installation, you can access various features of **cli-top** by running sp - [Shruthilaya K](https://github.com/shruthilayak11) - [Srijan Srivastava](https://github.com/Srijan1202) - ## License Copyright (C) 2023-2026 ACM-VIT and cli-top contributors. diff --git a/cmd/creds.go b/cmd/creds.go index c0632262..7f60623f 100644 --- a/cmd/creds.go +++ b/cmd/creds.go @@ -4,6 +4,7 @@ import ( "cli-top/debug" "cli-top/helpers" "fmt" + "os" "strings" "github.com/spf13/cobra" @@ -18,24 +19,61 @@ var credCmd = &cobra.Command{ Use: "login", Short: "Login to VTOP", Run: func(cmd *cobra.Command, args []string) { - helpers.Println("NOTE: Your password will be visible.") - username := promptInput("Enter your username: ") - password := promptInput("Enter your password: ") - key := GenerateAESKey() + username, _ := cmd.Flags().GetString("username") + password, _ := cmd.Flags().GetString("password") + if strings.TrimSpace(username) == "" { + username = promptInput("Enter your username: ") + } + if password == "" { + helpers.Println("NOTE: Your password will be visible.") + password = promptInput("Enter your password: ") + } + username = strings.ToUpper(strings.TrimSpace(username)) + if username == "" || password == "" { + helpers.Println("Username and password are required.") + return + } + + key, err := GenerateAESKey() + if err != nil { + if debug.Debug { + helpers.Println(err) + } else { + helpers.Println("Unable to initialize secure credential storage.") + } + return + } encryptedPassword, err := encryptPassword(password, key) - if err != nil && debug.Debug { - helpers.Println("Error encrypting password:", err) + if err != nil { + if debug.Debug { + helpers.Println("Error encrypting password:", err) + } else { + helpers.Println("Unable to securely store the password.") + } return } - helpers.Printf("Logging in with username: %s\n", strings.ToUpper(username)) - viper.Set("VTOP_USERNAME", "\""+strings.ToUpper(username)+"\"") + helpers.Printf("Logging in with username: %s\n", username) + viper.Set("VTOP_USERNAME", "\""+username+"\"") viper.Set("PASSWORD", "\""+encryptedPassword+"\"") viper.Set("KEY", "\""+key+"\"") - if err := viper.WriteConfigAs(exeConfigPath()); err != nil && debug.Debug { - helpers.Println("Error writing to .env file:", err) + configPath := exeConfigPath() + if err := viper.WriteConfigAs(configPath); err != nil { + if debug.Debug { + helpers.Println("Error writing to .env file:", err) + } else { + helpers.Println("Unable to write the cli-top configuration.") + } + return + } + if err := os.Chmod(configPath, 0o600); err != nil { + if debug.Debug { + helpers.Println("Error securing config file permissions:", err) + } else { + helpers.Println("Unable to secure the cli-top configuration.") + } return } @@ -53,8 +91,8 @@ func promptInput(prompt string) string { func init() { credCmd.Flags().String("username", "", "Enter VTOP username") credCmd.Flags().String("password", "", "Enter VTOP password") - credCmd.Flags().String("regno", "", "Enter VIT registration number") viper.SetConfigType("env") + viper.SetConfigPermissions(0o600) viper.SetConfigFile(exeConfigPath()) viper.ReadInConfig() rootCmd.AddCommand(credCmd) diff --git a/cmd/encrypt.go b/cmd/encrypt.go index 2dffbd5c..1c7054ef 100644 --- a/cmd/encrypt.go +++ b/cmd/encrypt.go @@ -1,8 +1,6 @@ package cmd import ( - "cli-top/debug" - "cli-top/helpers" "crypto/aes" "crypto/cipher" "crypto/rand" @@ -10,29 +8,24 @@ import ( "fmt" ) -func GenerateAESKey() string { +func GenerateAESKey() (string, error) { key := make([]byte, 32) - _, err := rand.Read(key) - if err != nil && debug.Debug { - helpers.Println("error generating key") + if _, err := rand.Read(key); err != nil { + return "", fmt.Errorf("generate encryption key: %w", err) } - keyBase64 := base64.URLEncoding.EncodeToString(key)[:32] - return keyBase64 + return base64.URLEncoding.EncodeToString(key)[:32], nil } func encryptPassword(password string, key string) (string, error) { - if debug.Debug { - helpers.Println("key", key) - } block, err := aes.NewCipher([]byte(key)) - if err != nil && debug.Debug { + if err != nil { return "", err } cipherText := make([]byte, aes.BlockSize+len(password)) iv := cipherText[:aes.BlockSize] - if _, err := rand.Read(iv); err != nil && debug.Debug { + if _, err := rand.Read(iv); err != nil { return "", err } @@ -44,12 +37,12 @@ func encryptPassword(password string, key string) (string, error) { func decryptPassword(encryptedPassword string, key string) (string, error) { decoded, err := base64.URLEncoding.DecodeString(encryptedPassword) - if err != nil && debug.Debug { + if err != nil { return "", err } block, err := aes.NewCipher([]byte(key)) - if err != nil && debug.Debug { + if err != nil { return "", err } diff --git a/cmd/encrypt_test.go b/cmd/encrypt_test.go new file mode 100644 index 00000000..512e9428 --- /dev/null +++ b/cmd/encrypt_test.go @@ -0,0 +1,33 @@ +package cmd + +import "testing" + +func TestPasswordEncryptionRoundTrip(t *testing.T) { + key, err := GenerateAESKey() + if err != nil { + t.Fatal(err) + } + encrypted, err := encryptPassword("correct horse battery staple", key) + if err != nil { + t.Fatal(err) + } + if encrypted == "correct horse battery staple" { + t.Fatal("password was stored as plaintext") + } + decrypted, err := decryptPassword(encrypted, key) + if err != nil { + t.Fatal(err) + } + if decrypted != "correct horse battery staple" { + t.Fatalf("decrypted password = %q", decrypted) + } +} + +func TestPasswordEncryptionRejectsInvalidInput(t *testing.T) { + if _, err := encryptPassword("secret", "short"); err == nil { + t.Fatal("encryptPassword accepted an invalid key") + } + if _, err := decryptPassword("not-base64", "01234567890123456789012345678901"); err == nil { + t.Fatal("decryptPassword accepted invalid ciphertext") + } +} diff --git a/cmd/logo.go b/cmd/logo.go index 52e874da..52e08063 100644 --- a/cmd/logo.go +++ b/cmd/logo.go @@ -14,4 +14,4 @@ func logo() string { ` // print(Logo) return Logo -} \ No newline at end of file +} diff --git a/cmd/proxy_command.go b/cmd/proxy_command.go index 1b3a9dc8..6ed3280e 100644 --- a/cmd/proxy_command.go +++ b/cmd/proxy_command.go @@ -50,25 +50,6 @@ type proxyError struct { Message string `json:"message"` } -var interactiveProxyCommands = map[string]struct{}{ - "timetable": {}, - "holiday": {}, - "today": {}, - "tomorrow": {}, - "dayafter": {}, - "marks": {}, - "grades": {}, - "calendar": {}, - "events": {}, - - "course-page": {}, - "course-page-archive": {}, - "course-allocation": {}, - "da": {}, - "facility": {}, - "syllabus": {}, -} - var defaultSyncCommands = []string{"profile", "timetable", "attendance", "marks", "cgpa", "exams"} type syncResultEntry struct { @@ -131,23 +112,16 @@ func runProxyCommand(args []string) { } }() + restoreLogin := helpers.SetVtopLoginHandler(func() (types.Cookies, string, error) { + return proxyLogin(username, password) + }) + defer restoreLogin() + if command == "sync" { runSyncProxyCommand(&resp, flags, cookies, regNo, start) return } - prevLogin := helpers.VtopLoginGlobal - helpers.VtopLoginGlobal = func() (types.Cookies, string) { - freshCookies, freshReg, loginErr := proxyLogin(username, password) - if loginErr != nil { - return types.Cookies{}, "" - } - return freshCookies, freshReg - } - defer func() { - helpers.VtopLoginGlobal = prevLogin - }() - tableSnapshots := []helpers.TableSnapshot{} restore := helpers.RegisterTableCaptureHook(func(snapshot helpers.TableSnapshot) { tableSnapshots = append(tableSnapshots, snapshot) @@ -260,9 +234,9 @@ func executeFeatureCommand(command string, flags map[string]string, cookies type case "profile": features.Profile(cookies, regNo) case "marks": - features.GetMarks(regNo, cookies, "", parseIntFlag(flags, "semester")) + features.GetMarks(regNo, cookies, parseIntFlag(flags, "semester")) case "grades": - features.GetGrades(regNo, cookies, "", parseIntFlag(flags, "semester")) + features.GetGrades(regNo, cookies, parseIntFlag(flags, "semester")) case "attendance": features.GetAttendance(regNo, cookies, parseIntFlag(flags, "semester")) case "timetable": @@ -296,7 +270,6 @@ func executeFeatureCommand(command string, flags map[string]string, cookies type parseIntFlag(flags, "semester"), parseIntFlag(flags, "course"), flagsValue(flags, "faculty"), - parseIntFlag(flags, "fuzzyIndex"), flagsValue(flags, "materials"), ) case "course-page-archive": @@ -306,7 +279,6 @@ func executeFeatureCommand(command string, flags map[string]string, cookies type parseIntFlag(flags, "semester"), parseIntFlag(flags, "course"), flagsValue(flags, "faculty"), - parseIntFlag(flags, "fuzzyIndex"), flagsValue(flags, "materials"), ) case "course-allocation": @@ -317,6 +289,8 @@ func executeFeatureCommand(command string, flags map[string]string, cookies type CostCentreID: flagsValue(flags, "cost-centre-id"), AppliedTo: flagsValue(flags, "applied-to"), RoomTypeID: flagsValue(flags, "room-type-id"), + BuildingID: flagsValue(flags, "building-id"), + Venue: flagsValue(flags, "venue"), LateHourEventID: flagsValue(flags, "event-id"), Details: flagsValue(flags, "details"), FromDate: flagsValue(flags, "from-date"), @@ -485,8 +459,3 @@ func flagsValue(flags map[string]string, key string) string { } return flags[key] } - -func isInteractiveProxyCommand(command string) bool { - _, ok := interactiveProxyCommands[command] - return ok -} diff --git a/cmd/start.go b/cmd/start.go index 5f005a2f..1f62a012 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -1,17 +1,12 @@ package cmd import ( - "bytes" "cli-top/debug" "cli-top/features" "cli-top/helpers" "cli-top/login" types "cli-top/types" - "encoding/json" "fmt" - "io" - "log" - "net/http" "os" "strings" "time" @@ -29,7 +24,6 @@ var updateFlag bool var courseFlag int var facultyFlag string var classGrpFlag int -var fuzzyIndexFlag int var courseNameFlag string var syllabusCourseFlag string var coursePageMaterialsFlag string @@ -75,85 +69,6 @@ func getOrCreateUUID() string { return unregisteredUUID } -func trackCommand(command string) { - userUUID := viper.GetString("UUID") - if userUUID == "" { - if debug.Debug { - log.Println("UUID is empty or not initialized. Skipping tracking.") - } - return - } - - data := types.TrackingData{ - UUID: userUUID, - Command: command, - Timestamp: time.Now().Format(time.RFC3339), - } - - jsonData, err := json.Marshal(data) - if err != nil { - if debug.Debug { - log.Println("Error marshaling tracking data:", err) - } - return - } - - serverURL := helpers.CalendarServerURL + "/track" - - req, err := http.NewRequest("POST", serverURL, bytes.NewBuffer(jsonData)) - if err != nil { - if debug.Debug { - log.Println("Error creating tracking request:", err) - } - return - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("x-api-key", data.UUID) - - client := &http.Client{Timeout: 10 * time.Second} - - // Send the POST request asynchronously - resp, err := client.Do(req) - if err != nil { - if debug.Debug { - log.Println("Error sending tracking data:", err) - } - return - } - defer resp.Body.Close() - - // Discard the response body to free resources - io.Copy(io.Discard, resp.Body) - - if resp.StatusCode == http.StatusUnauthorized { - if debug.Debug { - log.Println("Invalid UUID detected. Generating a new one and registering...") - } - newUUID := uuid.New().String() - err = helpers.RegisterUUID(newUUID) - if err != nil { - if debug.Debug { - log.Println("Failed to register new UUID:", err) - } - return - } - viper.Set("UUID", newUUID) - viper.Set("UNREGISTERED_UUID", "") - if err := viper.WriteConfigAs(configFilePath()); err != nil && debug.Debug { - helpers.Println("Error updating registered UUID in config:", err) - } - } else if resp.StatusCode != http.StatusOK { - if debug.Debug { - log.Println("Unexpected response status during tracking:", resp.Status) - } - } else { - if debug.Debug { - log.Println("Tracking data sent successfully.") - } - } -} - func startfn() { reset := "\x1b[0m" grays := []string{ @@ -180,38 +95,16 @@ func startfn() { fmt.Printf("%s%s%s\n", colorCode, line, reset) } fmt.Printf("\n%sWelcome to CLI-TOP!%s\n\n", text, reset) - fmt.Printf("%sUse \"cli-top help\" or \"cli-top --list\" to show available commands%s\n", dim, reset) + fmt.Printf("%sUse \"cli-top help\" to show available commands%s\n", dim, reset) fmt.Printf("%sUse \"cli-top [command] --help\" for more information about a command.%s\n\n", dim, reset) filePath := configFilePath() - if _, err := os.Stat(filePath); err == nil { - if debug.Debug { - helpers.Println("File exists:", filePath) - } - err := godotenv.Load(filePath) - helpers.LoadSemesterCacheFromEnv() - if err != nil && debug.Debug { - helpers.Println("Error loading .env file") - } - if debug.Debug { - helpers.Println(os.Getenv("PASSWORD")) - } - - if os.Getenv("VTOP_USERNAME") != "" && os.Getenv("PASSWORD") != "" { - vtop_login() - } - } else { - // File not found in cwd or exe dir + if _, err := os.Stat(filePath); os.IsNotExist(err) { if debug.Debug { helpers.Println("File does not exist:", filePath) } helpers.Println("Please login using the \"login\" command") } - - userUUID := getOrCreateUUID() - if debug.Debug { - helpers.Println("User UUID:", userUUID) - } } func vtop_login() (types.Cookies, string) { @@ -229,39 +122,52 @@ func vtop_login() (types.Cookies, string) { key := os.Getenv("KEY") password, err := decryptPassword(userInfo.Password, key) - if err != nil && debug.Debug { - helpers.Println("Error decrypting password:", err) + if err != nil { + if debug.Debug { + helpers.Println("Error decrypting password:", err) + } else { + helpers.Println("Stored credentials are invalid. Please run \"cli-top login\" again.") + } + return types.Cookies{}, "" } loginSecrets := login.Login(userInfo.Username, password) cookies, tmp := login.HomePage(loginSecrets) userInfo.RegNo = tmp - - saveCookiesToFile(cookies, userInfo, key) - if err != nil && debug.Debug { - helpers.Println("Error saving cookies:", err) + if !helpers.ValidateCookies(cookies) || userInfo.RegNo == "" { + helpers.Println("Unable to log in. Please verify your credentials with \"cli-top login\".") + return types.Cookies{}, "" } - if debug.Debug { - helpers.Println("(Main) VTOP Cookies", cookies) + + if err := saveCookiesToFile(cookies, userInfo, key); err != nil { + if debug.Debug { + helpers.Println("Error saving session:", err) + } + return types.Cookies{}, "" } return cookies, userInfo.RegNo } -func saveCookiesToFile(cookies types.Cookies, userInfo types.LogIn, Key string) { +func saveCookiesToFile(cookies types.Cookies, userInfo types.LogIn, key string) error { viper.Set("CSRF", "\""+cookies.CSRF+"\"") viper.Set("JSESSIONID", "\""+cookies.JSESSIONID+"\"") viper.Set("SERVERID", "\""+cookies.SERVERID+"\"") viper.Set("REGNO", "\""+userInfo.RegNo+"\"") viper.Set("VTOP_USERNAME", "\""+userInfo.Username+"\"") viper.Set("PASSWORD", "\""+userInfo.Password+"\"") - viper.Set("KEY", "\""+Key+"\"") - if err := viper.WriteConfigAs(configFilePath()); err != nil && debug.Debug { - helpers.Println("Error writing to .env file:", err) + viper.Set("KEY", "\""+key+"\"") + path := configFilePath() + if err := viper.WriteConfigAs(path); err != nil { + return err + } + if err := os.Chmod(path, 0o600); err != nil { + return err } if userInfo.RegNo != "" { helpers.InvalidateSemesterCache(userInfo.RegNo) } + return nil } func readCookiesFromFile() (types.Cookies, string) { @@ -418,28 +324,22 @@ var rootCmd = &cobra.Command{ return } - if cmd.Name() != "login" && cmd.Name() != "logout" && cmd.Name() != "cli-top" && cmd.Name() != "proxy" { - go trackCommand(cmd.Name()) + switch cmd.Name() { + case "login", "logout", "cli-top", "proxy": + return } - if cmd.Name() != "login" && cmd.Name() != "logout" && cmd.Name() != "proxy" { - commandName := cmd.Name() - go func() { - userUUID := viper.GetString("UUID") - if userUUID == "" { - return - } - - data := types.VersionTrackingData{ - UUID: userUUID, - Command: commandName, - Version: debug.Version, - Timestamp: time.Now().Format(time.RFC3339), - } - - helpers.SendVersionTrackingData(data) - }() + userUUID := viper.GetString("UUID") + if userUUID == "" { + return + } + data := types.VersionTrackingData{ + UUID: userUUID, + Command: cmd.Name(), + Version: debug.Version, + Timestamp: time.Now().Format(time.RFC3339), } + go helpers.SendVersionTrackingData(data) }, Run: func(cmd *cobra.Command, args []string) { @@ -463,8 +363,13 @@ var rootCmd = &cobra.Command{ } func init() { - helpers.VtopLoginGlobal = vtop_login - helpers.DecryptPasswordProxy = decryptPassword + helpers.SetVtopLoginHandler(func() (types.Cookies, string, error) { + cookies, regNo := vtop_login() + if !helpers.ValidateCookies(cookies) || strings.TrimSpace(regNo) == "" { + return types.Cookies{}, "", fmt.Errorf("failed to refresh VTOP session") + } + return cookies, regNo, nil + }) rootCmd.SetUsageTemplate(`Usage: {{.CommandPath}} [global flags] [subcommand flags] [arguments] @@ -483,6 +388,7 @@ Use "{{.CommandPath}} --help" for more information about a subcomma // Define flags for subcommands. marksCmd.PersistentFlags().IntVarP(&semesterFlag, "semester", "s", 0, "Specify the semester") gradesCmd.PersistentFlags().IntVarP(&semesterFlag, "semester", "s", 0, "Specify the semester") + attendanceCmd.PersistentFlags().IntVarP(&semesterFlag, "semester", "s", 0, "Specify the semester") timeTableCmd.PersistentFlags().IntVarP(&semesterFlag, "semester", "s", 0, "Specify the semester") holidayCmd.PersistentFlags().IntVarP(&semesterFlag, "semester", "s", 0, "Specify the semester") holidayCmd.PersistentFlags().IntVarP(&classGrpFlag, "class-group", "g", 0, "Specify the class group") @@ -500,12 +406,10 @@ Use "{{.CommandPath}} --help" for more information about a subcomma coursePageCmd.PersistentFlags().IntVarP(&semesterFlag, "semester", "s", 0, "Specify the semester") coursePageCmd.PersistentFlags().IntVarP(&courseFlag, "course", "c", 0, "Specify the course") coursePageCmd.PersistentFlags().StringVarP(&facultyFlag, "faculty", "f", "", "Specify the faculty") - coursePageCmd.PersistentFlags().IntVarP(&fuzzyIndexFlag, "fuzzy-index", "i", 0, "Specify the fuzzy index") coursePageCmd.PersistentFlags().StringVar(&coursePageMaterialsFlag, "materials", "", "Specify material indices or ranges (e.g. 1,2-4 or 0 for all)") coursePageArchiveCmd.PersistentFlags().IntVarP(&semesterFlag, "semester", "s", 0, "Specify the semester") coursePageArchiveCmd.PersistentFlags().IntVarP(&courseFlag, "course", "c", 0, "Specify the course") coursePageArchiveCmd.PersistentFlags().StringVarP(&facultyFlag, "faculty", "f", "", "Specify the faculty") - coursePageArchiveCmd.PersistentFlags().IntVarP(&fuzzyIndexFlag, "fuzzy-index", "i", 0, "Specify the fuzzy index") coursePageArchiveCmd.PersistentFlags().StringVar(&coursePageMaterialsFlag, "materials", "", "Specify material indices or ranges (e.g. 1,2-4 or 0 for all)") daDetailsCmd.PersistentFlags().StringVarP(&courseNameFlag, "course", "c", "", "Specify subject search query or index") daDetailsCmd.PersistentFlags().StringVarP(&daAssignmentFlag, "assignment", "a", "", "Specify assignment search query or index") @@ -651,7 +555,7 @@ var marksCmd = &cobra.Command{ Short: "Show Marks Details of a particular semester", Run: helpers.CommandRunner("marks", func(cmd *cobra.Command, args []string) { cookies, regNo := readCookiesFromFile() - features.GetMarks(regNo, cookies, "", semesterFlag) + features.GetMarks(regNo, cookies, semesterFlag) }), } @@ -660,7 +564,7 @@ var gradesCmd = &cobra.Command{ Short: "Show Grade Details of a particular semester", Run: helpers.CommandRunner("grades", func(cmd *cobra.Command, args []string) { cookies, regNo := readCookiesFromFile() - features.GetGrades(regNo, cookies, "", semesterFlag) + features.GetGrades(regNo, cookies, semesterFlag) }), } @@ -760,7 +664,7 @@ var coursePageCmd = &cobra.Command{ Short: "Download course materials for a selected semester, course, and faculty", Run: helpers.CommandRunner("course-page", func(cmd *cobra.Command, args []string) { cookies, regNo := readCookiesFromFile() - features.ExecuteCoursePageDownload(regNo, cookies, semesterFlag, courseFlag, facultyFlag, fuzzyIndexFlag, coursePageMaterialsFlag) + features.ExecuteCoursePageDownload(regNo, cookies, semesterFlag, courseFlag, facultyFlag, coursePageMaterialsFlag) }), } @@ -769,7 +673,7 @@ var coursePageArchiveCmd = &cobra.Command{ Short: "Download course materials for a selected semester, course, and faculty (Archive)", Run: helpers.CommandRunner("course-page-archive", func(cmd *cobra.Command, args []string) { cookies, regNo := readCookiesFromFile() - features.ExecuteCoursePageOldDownload(regNo, cookies, semesterFlag, courseFlag, facultyFlag, fuzzyIndexFlag, coursePageMaterialsFlag) + features.ExecuteCoursePageOldDownload(regNo, cookies, semesterFlag, courseFlag, facultyFlag, coursePageMaterialsFlag) }), } @@ -797,39 +701,22 @@ var logoutCmd = &cobra.Command{ Run: helpers.CommandRunner("logout", func(cmd *cobra.Command, args []string) { err := godotenv.Load(configFilePath()) helpers.LoadSemesterCacheFromEnv() - if err != nil && debug.Debug { + if err != nil && !os.IsNotExist(err) && debug.Debug { helpers.Println("Error loading .env file:", err) - return - } - - uuid := os.Getenv("UUID") - - if uuid == "" { - helpers.Println("UUID not found; nothing to preserve.") - return } - env := map[string]string{ - "UUID": uuid, + content := "" + if userUUID := strings.TrimSpace(os.Getenv("UUID")); userUUID != "" { + content = fmt.Sprintf("UUID=%s\n", userUUID) } - - // create the config file at the discovered path - f, err := os.Create(configFilePath()) - if err != nil { + if err := os.WriteFile(configFilePath(), []byte(content), 0o600); err != nil { if debug.Debug { - helpers.Println("Error creating .env file:", err) + helpers.Println("Error clearing .env file:", err) + } else { + helpers.Println("Unable to clear the cli-top configuration.") } return } - defer f.Close() - - for key, value := range env { - _, err = f.WriteString(fmt.Sprintf("%s=%s\n", key, value)) - if err != nil && debug.Debug { - helpers.Println("Error writing to .env file:", err) - return - } - } helpers.Println("Logged out successfully.") }), @@ -893,8 +780,9 @@ func hasLeaveApplyFields(cmd *cobra.Command) bool { } var classMessagesCmd = &cobra.Command{ - Use: "msg", - Short: "Show Class Messages", + Use: "msg", + Aliases: []string{"class-messages"}, + Short: "Show Class Messages", Run: helpers.CommandRunner("msg", func(cmd *cobra.Command, args []string) { cookies, regNo := readCookiesFromFile() features.GetClassMessage(regNo, cookies) diff --git a/debug/debug.go b/debug/debug.go index ca016a7a..0df0d808 100644 --- a/debug/debug.go +++ b/debug/debug.go @@ -1,16 +1,4 @@ package debug -import ( - "fmt" - "time" -) - var Debug bool = false var Version string = "2.12.0" - -func Log(message string) { - if Debug { - timestamp := time.Now().Format("15:04:05") - fmt.Printf("[DEBUG %s] %s\n", timestamp, message) - } -} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f02eda7d..f10c29ed 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1,143 +1,84 @@ -# CLI-TOP Features +# cli-top command reference -CLI-TOP is a command-line interface for VIT's VTOP portal. Here are all the available commands and their usage: +Use `cli-top --help` to list public commands and `cli-top COMMAND --help` to inspect a command. -## Global Flags -- `-d, --debug`: Print Debug Messages -- `-u, --update`: Check for Updates -- `-v, --version`: Print Version Number +## Global flags -## Commands +| Flag | Effect | +| --- | --- | +| `-d`, `--debug` | Enable debug output. | +| `-h`, `--help` | Show help. | +| `-u`, `--update` | Check for updates when used as `cli-top --update`. | +| `-v`, `--version` | Print the installed version when used as `cli-top --version`. | -### Academic Information +## Account -#### Profile -```bash -cli-top profile -``` -Shows your VTOP Student Profile information. - -#### Marks -```bash -cli-top marks [-s SEMESTER] -``` -Shows marks details for a particular semester. - -#### Grades -```bash -cli-top grades [-s SEMESTER] -``` -Shows grade details for a particular semester. - -#### CGPA -```bash -cli-top cgpa -``` -Shows your CGPA details. - -### Course Management - -#### Course Page -```bash -cli-top course-page [-s SEMESTER] [-c COURSE] [-f FACULTY] [-i FUZZY_INDEX] -``` -Download course materials for a selected semester, course, and faculty. +### Login -#### Syllabus ```bash -cli-top syllabus [-c COURSE] +cli-top login [--username USERNAME] [--password PASSWORD] ``` -Download syllabus for a selected course. -#### Attendance -```bash -cli-top attendance [-s SEMESTER] -``` -Shows attendance details for a particular semester. +Stores the username and encrypted password in cli-top's local configuration. If either value is omitted, cli-top prompts for it. This command does not open a VTOP session; the next portal-backed command authenticates with the stored credentials. -#### Timetable -```bash -cli-top timetable [-s SEMESTER] -``` -Shows time table for a particular semester. - -#### Holiday -```bash -cli-top holiday [-s SEMESTER] [-g CLASS_GROUP] -``` -Shows upcoming class-impacting holidays for a particular semester. +### Logout -#### Today ```bash -cli-top today [-s SEMESTER] [-g CLASS_GROUP] +cli-top logout ``` -Shows today's effective schedule and whether your current attendance gives you room to skip each class. -#### Tomorrow -```bash -cli-top tomorrow [-s SEMESTER] [-g CLASS_GROUP] -``` -Shows tomorrow's effective schedule and whether your current attendance gives you room to skip each class. +Clears stored VTOP credentials and session data. -#### Day After -```bash -cli-top dayafter [-s SEMESTER] [-g CLASS_GROUP] -``` -Shows the day after tomorrow's effective schedule and whether your current attendance gives you room to skip each class. +## Academic records -#### Calendar -```bash -cli-top calendar [-s SEMESTER] [-g CLASS_GROUP] -``` -Shows calendar with class schedule. +| Command | Flags | Purpose | +| --- | --- | --- | +| `cli-top profile` | — | Show the student profile. | +| `cli-top marks` | `-s`, `--semester INDEX` | Show marks for a semester. | +| `cli-top grades` | `-s`, `--semester INDEX` | Show grades for a semester. | +| `cli-top cgpa` | — | Show registered and earned credits, CGPA, and grade counts. | +| `cli-top attendance` | `-s`, `--semester INDEX` | Show attendance and skip/attend guidance. | +| `cli-top exams` | `-s`, `--semester INDEX` | Show the upcoming exam schedule. | -### Examination +Without `--semester`, `attendance` and `exams` search recent semesters for available data. Commands with an interactive semester picker prompt when no selection is supplied. -#### Exam Schedule -```bash -cli-top exams [-s SEMESTER] -``` -Shows exam schedule for a particular semester. +## Schedule and planning -### Campus Services +| Command | Flags | Purpose | +| --- | --- | --- | +| `cli-top timetable` | `-s`, `--semester INDEX` | Show a semester timetable. | +| `cli-top holiday` | `-s`, `--semester INDEX`; `-g`, `--class-group INDEX` | Show upcoming class-impacting holidays. | +| `cli-top today` | `-s`, `--semester INDEX`; `-g`, `--class-group INDEX` | Show today's effective schedule and attendance-aware skip guidance. | +| `cli-top tomorrow` | `-s`, `--semester INDEX`; `-g`, `--class-group INDEX` | Show tomorrow's effective schedule and attendance-aware skip guidance. | +| `cli-top dayafter` | `-s`, `--semester INDEX`; `-g`, `--class-group INDEX` | Show the same planner for the day after tomorrow. Alias: `day-after`. | +| `cli-top calendar` | `-s`, `--semester INDEX`; `-g`, `--class-group INDEX` | Show the academic calendar with class scheduling. | -#### Facility -```bash -cli-top facility -``` -View or register for physical facilities. +## Courses and downloads -#### Hostel -```bash -cli-top hostel -``` -Shows your hostel details. +| Command | Flags | Purpose | +| --- | --- | --- | +| `cli-top course-allocation` | `--category QUERY_OR_INDEX`; `-c`, `--course QUERY_OR_INDEX` | Browse course-allocation details. | +| `cli-top course-page` | `-s`, `--semester INDEX`; `-c`, `--course INDEX`; `-f`, `--faculty QUERY_OR_INDEX`; `--materials LIST` | Download materials from the current consolidated course page. | +| `cli-top course-page-archive` | `-s`, `--semester INDEX`; `-c`, `--course INDEX`; `-f`, `--faculty QUERY_OR_INDEX`; `--materials LIST` | Download materials through the older course-page workflow. | +| `cli-top syllabus` | `-c`, `--course QUERY_OR_INDEX` | Download a course syllabus. | +| `cli-top da` | `-c`, `--course QUERY_OR_INDEX`; `-a`, `--assignment QUERY_OR_INDEX` | Review digital-assignment deadlines and submission status, and download available question papers. | -#### Library Dues -```bash -cli-top library-dues -``` -Shows your library dues. +For `--materials`, use comma-separated indices and ranges such as `1,2-4`, or `0` for all materials. Omit selection flags to use the interactive pickers. -#### Receipts -```bash -cli-top receipts -``` -Shows your receipt details. +## Campus services -#### Nightslip -```bash -cli-top nightslip -``` -Shows your nightslip request status first. If no request is pending, the command asks whether you want to apply and then walks you through the required details interactively before submission. +| Command | Flags | Purpose | +| --- | --- | --- | +| `cli-top events` | — | View upcoming club events and interactively register for an open event. | +| `cli-top facility` | `-f`, `--facility QUERY_OR_INDEX`; `--confirm` | View or register for a physical facility. | +| `cli-top hostel` | — | Show hostel details. | +| `cli-top library-dues` | — | Show library dues. | +| `cli-top receipts` | — | Show receipts and payment history. | +| `cli-top nightslip` | — | Show nightslip status and, when none is pending, optionally apply interactively. | +| `cli-top leave` | See below. | Show leave status and, when none is pending, optionally apply interactively. | -#### Leave -```bash -cli-top leave -``` -Shows your leave request status first. If no request is pending, the command asks whether you want to apply and then walks you through the leave type, place, reason, dates, and times interactively before submission. +Submit a leave request non-interactively with all required application fields: -For deterministic automation, submit non-interactively with explicit flags: ```bash cli-top leave --apply \ --leave-code HT1 \ @@ -148,48 +89,24 @@ cli-top leave --apply \ --to-date 2026-04-24 \ --to-time 06:30 ``` -The command validates the leave type locally against known VTOP codes, normalizes dates and times, checks the date/time range, rejects malformed place/reason text before opening the leave workflow, and verifies the leave type against VTOP's form before posting. -### Communication +The leave application flags are `--apply`, `--leave-code`, `--visiting-place`, `--reason`, `--from-date`, `--from-time`, `--to-date`, and `--to-time`. Application fields are rejected unless `--apply` is present. -#### Class Messages -```bash -cli-top class-messages -``` -Shows class messages. +## Communication -### Account Management - -#### Logout ```bash -cli-top logout +cli-top msg +cli-top class-messages ``` -Logs out from VTOP. -## Usage Examples +Both forms show class messages. `msg` is the canonical command and `class-messages` is its alias. -1. Check your current semester's attendance: -```bash -cli-top attendance -``` +## Shell completion -2. Download course materials for a specific semester: ```bash -cli-top course-page -s 1 +cli-top completion SHELL ``` -3. View exam schedule: -```bash -cli-top exams -``` - -4. Check your grades for a specific semester: -```bash -cli-top grades -s 2 -``` +Supported shells are `bash`, `fish`, `powershell`, and `zsh`. -## Notes -- Most semester-specific commands will prompt for semester selection if the `-s` flag is not provided -- The course page download supports fuzzy search for easier course selection -- All commands require you to be logged in first -- Use `cli-top [command] --help` for more information about a specific command +All portal-backed feature commands require stored credentials. See the project [README](../README.md) for installation and first-use examples. diff --git a/docs/HELPERS.md b/docs/HELPERS.md index a7fe627a..04617b90 100644 --- a/docs/HELPERS.md +++ b/docs/HELPERS.md @@ -1,2295 +1,77 @@ -# CLI-TOP Helper Functions Documentation - -## Table of Contents -1. [Captcha Solver](#captcha-solver) -2. [Update and Control Module](#update-and-control-module) -3. [Table Renderer](#table-renderer) -4. [Semester Details](#semester-details) -5. [General Helpers](#general-helpers) -6. [Fuzzy Search](#fuzzy-search) -7. [Usage Tracking](#usage-tracking) -8. [Rate Limiting](#rate-limiting) -9. [ICS Generator](#ics-generator) -10. [Selection Helpers](#selection-helpers) -11. [HTTP Request Helpers](#http-request-helpers) -12. [Data Extraction](#data-extraction) -13. [Data Formatting](#data-formatting) - -# Captcha Solver - -## Overview -The Captcha Solver module is a sophisticated component that handles automated CAPTCHA recognition. - -## Core Components - -### Image Preprocessing Functions - -#### `preImg(img [][]int) [][]int` -Performs binary thresholding on the input image to separate foreground from background. - -**Implementation Details:** -- Calculates the average pixel value across the entire image -- Creates a binary image where pixels above average become 1, below become 0 -- Dimensions are preserved in the output - -**Usage Example:** -```go -binaryImage := preImg(grayscaleImage) -// Returns a binary image where text is separated from background -``` - -#### `saturation(d []uint8) [][][]int` -Processes raw image data to extract character regions based on saturation values. - -**Process Flow:** -1. Converts RGBA pixel data to saturation values -2. Reshapes the data into a 40x200 image matrix -3. Extracts 6 character blocks using specific coordinate calculations - -**Key Parameters:** -- Input expects raw pixel data in RGBA format (4 bytes per pixel) -- Output provides 6 separate character regions as 3D array - -### Matrix Operations - -#### `copySlice(src [][]int, transform func([]int) []int) [][]int` -Creates a deep copy of a 2D slice with custom transformation. - -**Features:** -- Supports custom transformation functions -- Preserves memory independence between source and destination -- Commonly used for character region extraction - -#### `flatten(arr [][]int) []int` and `flattenFloat32(arr [][]float32) []float32` -Convert multi-dimensional arrays to single-dimensional arrays. - -**Usage Context:** -- Prepare data for neural network input -- Maintain data continuity during matrix operations - -#### `matMul(a [][]int, b [][]float32) []float32` -Performs matrix multiplication between integer and float matrices. - -**Implementation Notes:** -- Optimized for neural network weight calculations -- Handles type conversion automatically -- Returns flattened result for immediate use - -#### `matAdd(a []float32, b []float32) []float32` -Performs element-wise addition of two float arrays. - -**Error Handling:** -- Requires equal-length input arrays -- Used primarily for adding bias terms in neural network - -### Neural Network Components - -#### `maxSoft(a []float32) []float32` -Implements the softmax activation function for classification output. - -**Mathematical Process:** -1. Exponentiates each input value -2. Normalizes by sum of exponentials -3. Returns probabilities that sum to 1 - -#### `argmax(slice []float32) int` -Finds the index of the maximum value in a float array. - -**Implementation Details:** -- Uses custom key-value structure for sorting -- Maintains original indices during sorting -- Returns index of highest probability class - -### Main Function - -#### `SolveCaptcha(imageURL string) string` -Orchestrates the complete CAPTCHA solving process. - -**Process Flow:** -1. Validates input URL format (base64 JPEG) -2. Decodes base64 data to image -3. Processes image through neural network -4. Returns recognized characters - -**Kill Switch States:** -- 0: Automated solving enabled -- 1: Manual solving required -- 2: Completely disabled - -**Error Handling:** -- Handles base64 decoding errors -- Manages file operations safely -- Provides debug output when enabled - -## Usage Examples - -```go -// Example of solving a CAPTCHA -captchaURL := "data:image/jpeg;base64,..." -result := SolveCaptcha(captchaURL) -``` - -## Best Practices - -1. **Image Processing** - - Ensure input images are properly formatted JPEG - - Validate image dimensions before processing - - Handle memory efficiently for large images - -2. **Error Handling** - - Check debug.Debug flag before logging - - Clean up temporary files properly - - Validate all array dimensions before operations - -3. **Performance Optimization** - - Reuse allocated slices when possible - - Consider batch processing for multiple images - - Monitor memory usage for large images - -## Dependencies -- `cli-top/debug`: Debug configuration -- `encoding/base64`: Base64 encoding/decoding -- `image`, `image/jpeg`: Image processing -- `math`: Mathematical operations -- `sort`: Sorting operations - -# Update and Control Module - -## Overview -The Update and Control module manages version control and application behavior. - -## Version Management Function - -### `CheckUpdate()` -This function verifies whether the user is running the latest version of CLI-Top by comparing the local version against the remote version information. - -**Implementation Details:** - -The function follows a systematic process to check for updates: - -1. HTTP Client Setup -```go -client := &http.Client{} -req, err := http.NewRequest("GET", "https://cli-top.acmvit.in/latest.json", nil) -``` -The function creates a new HTTP client and request to fetch version information from the central server. The request is configured as a GET request to the latest.json endpoint which contains the information about the version of CLI-TOP and the kill-switch state. - -2. Version Comparison -```go -if !strings.Contains(string(bodyText), debug.Version) { - helpers.Println("A new version of cli-top is available.\nCheck out: https://cli-top.acmvit.in/ for the latest release.") -} -``` -The function performs a simple string comparison to determine if an update is available. This comparison relies on the debug.Version constant being present in the response if the user has the latest version. - -## Application Control Function - -### `CheckKillSwitch() int` -This function implements a remote control mechanism for the application, allowing administrators to modify application behavior based on system requirements or security concerns. - -**Return Values:** -- `0`: Normal operation - automated captcha solver enabled -- `1`: Restricted operation - manual captcha solving required -- `2`: Complete shutdown - application disabled -- `3`: Complete shutdown + open VTOP in browser -- `4`: Disable facility registration - -**Implementation Details:** - -1. Kill Switch State Detection -```go -if strings.Contains(string(bodyText), "\"killSwitch\": 2") { - return 2 // Complete shutdown -} else if strings.Contains(string(bodyText), "\"killSwitch\": 0") { - return 0 // Normal operation -} -return 1 // Restricted operation which requires manual captcha solving -``` -The function uses string matching to determine the current kill switch state, implementing a fallback mechanism that defaults to restricted operation. - -# Table Renderer - -## Overview -The Table Renderer module is a sophisticated component that provides functionality for creating, displaying, and interacting with formatted tables in the terminal. It supports both direct numeric selection and fuzzy search capabilities, making it a versatile tool for user interaction. - -## Core Components - -### Text Processing Functions - -#### `StripAnsiCodes(str string) string` -Removes ANSI escape sequences from strings while preserving the visible text. - -**Implementation Details:** -- Removes standard ANSI CSI sequences (colors, formatting) -- Handles ANSI hyperlink sequences -- Preserves visible text content -- Uses regular expressions for pattern matching - -**Process Flow:** -1. Removes standard color/formatting codes -2. Extracts visible text from hyperlinks -3. Cleans up any remaining hyperlink markers - -### Selection Types and Interfaces - -#### `FuzzySearchFunc type` -Function type for implementing custom fuzzy search algorithms. - -**Definition:** -```go -type FuzzySearchFunc func([][]string, string) []int -``` - -#### `SelectionResult struct` -Structure representing the outcome of a table selection operation. - -**Fields:** -- `Index`: Selected row index -- `Selected`: Whether a selection was made -- `ExitRequest`: Whether user requested to exit - -### Selection Functions - -#### `TableSelector(subject string, nestedList [][]string, initialQuery string) SelectionResult` -Handles direct numeric selection from a table. - -**Implementation Details:** -- Supports initial query processing -- Validates numeric inputs -- Provides interactive selection interface - -**Process Flow:** -1. Processes initial query if provided -2. Displays formatted table -3. Handles user input validation -4. Returns selection result - -**Error Handling:** -- Validates numeric input range -- Provides clear error messages -- Allows exit command - -#### `TableSelectorFuzzy(subject string, nestedList [][]string, initialQuery string, fuzzySearchFunc FuzzySearchFunc) SelectionResult` -Advanced selection interface with fuzzy search support. - -**Features:** -- Supports both direct selection and fuzzy search -- Handles initial queries -- Provides filtered results -- Customizable search function - -**Process Flow:** -1. Attempts direct selection if numeric -2. Performs fuzzy search for text input -3. Handles single and multiple matches -4. Provides interactive refinement - -**Error States:** -- Invalid numeric input -- No search matches -- Out of range selections - -### Display Functions - -#### `PrintTable(nestedList [][]string, indexStatus int) int` -Renders a formatted table with headers and data rows. - -**Implementation Details:** -- Calculates optimal column widths -- Handles Unicode characters -- Supports ANSI color codes -- Manages multi-line content - -**Features:** -- Dynamic width calculation -- Header row highlighting -- Index column management -- Proper spacing and alignment - -**Usage Example:** -```go -data := [][]string{ - {"Header1", "Header2"}, - {"Data1", "Data2"}, -} -PrintTable(data, 1) // 1 indicates to show index column -``` - -### Search Functions - -#### `NewFuzzySearch(nestedList [][]string, stringFlag string) []int` -Default fuzzy search implementation. - -**Algorithm Details:** -- Case-insensitive matching -- Partial string matching -- Returns matching indices -- Handles multiple matches - -## Usage Examples - -```go -// Basic table display -data := [][]string{ - {"ID", "Name", "Status"}, - {"1", "Item One", "Active"}, - {"2", "Item Two", "Inactive"}, -} -PrintTable(data, 1) - -// Interactive selection with fuzzy search -result := TableSelectorFuzzy("Item", data, "", nil) -if result.Selected { - helpers.Printf("Selected item: %s\n", data[result.Index][1]) -} -``` - -## Best Practices - -1. **Input Validation** - - Always validate numeric inputs - - Handle empty or invalid queries gracefully - - Provide clear error messages - -2. **Display Formatting** - - Consider terminal width constraints - - Handle Unicode characters properly - - Maintain consistent spacing - -3. **User Experience** - - Provide clear instructions - - Support both direct and fuzzy selection - - Allow easy exit options - -4. **Performance** - - Optimize for large datasets - - Cache calculated widths - - Minimize screen redraws - -## Dependencies -- `bufio`: Input/output operations -- `fmt`: Formatted I/O -- `os`: Operating system interface -- `regexp`: Regular expression support -- `strconv`: String conversions -- `strings`: String manipulation - -## Error Handling -- Validates all user inputs -- Provides clear error messages -- Supports graceful exit -- Handles edge cases (empty tables, invalid indices) - -## Security Considerations -- Sanitizes user input -- Handles ANSI escape sequences safely -- Prevents buffer overflows -- Validates array bounds - -# Semester Details - -## Overview -The Semester Details module is a critical component that manages the retrieval, parsing, and selection of academic semester information. It implements robust error handling and fallback mechanisms to ensure reliable semester data access even when primary methods fail. - -## Core Components - -### Data Retrieval Functions - -#### `GetSemDetails(cookies types.Cookies, regNo string) ([]types.Semester, error)` -Primary function for fetching semester information from the VTOP system. - -**Implementation Details:** -- Validates authentication tokens -- Makes HTTP request to attendance endpoint -- Parses HTML response for semester data -- Returns structured semester information - -**Process Flow:** -1. Validates cookie presence -2. Fetches data from attendance endpoint -3. Parses HTML document -4. Extracts semester information -5. Reverses list for chronological order - -**Error Handling:** -- Validates authentication state -- Handles network errors -- Manages parsing failures -- Provides debug information - -#### `GetSemDetailsBackup(cookies types.Cookies, regNo string) ([]types.Semester, error)` -Fallback function that attempts to retrieve semester data from an alternative endpoint. - -**Implementation Details:** -- Uses course page endpoint -- Follows same parsing logic as primary function -- Maintains consistent data structure - -**Usage Context:** -- Called when primary function fails -- Uses alternative data source -- Provides redundancy - -### HTML Parsing Functions - -#### `FindAndSaveSemIds(body []byte) ([]types.Semester, error)` -Extracts semester information from HTML document using multiple selector strategies. - -**Implementation Details:** -- Uses multiple CSS selectors for robustness -- Extracts semester IDs and names -- Validates extracted data -- Handles empty results - -**Selector Strategy:** -1. Tries form-select options -2. Attempts semester-specific selectors -3. Falls back to generic selectors -4. Validates found data - -**Error States:** -- Empty document handling -- Invalid selector handling -- Missing attribute handling -- Debug output for troubleshooting - -### Selection Interface - -#### `SelectSemester(regNo string, cookies types.Cookies, sem_choice int) (types.Semester, error)` -Provides interactive semester selection with both automatic and manual options. - -**Features:** -- Supports direct selection via parameter -- Falls back to interactive selection -- Handles user cancellation -- Validates selections - -**Process Flow:** -1. Retrieves semester list -2. Creates formatted table -3. Handles user input -4. Validates selection -5. Returns chosen semester - -**Error Handling:** -- Invalid selection handling -- User cancellation handling -- Data retrieval failures -- Input buffer management - -### Utility Functions - -#### `clearInputBuffer() error` -Manages input stream cleanliness for reliable user interaction. - -**Implementation Details:** -- Clears pending input -- Handles buffer states -- Manages error conditions -- Supports debug logging - -## Usage Examples - -```go -// Fetch semester details -cookies := types.Cookies{...} -semesters, err := GetSemDetails(cookies, "12345") -if err != nil { - // Handle error or try backup - semesters, err = GetSemDetailsBackup(cookies, "12345") -} - -// Select semester with automatic choice -selectedSem, err := SelectSemester(regNo, cookies, 1) -if err != nil { - // Handle error -} -``` - -## Best Practices - -1. **Error Handling** - - Always check authentication state - - Implement fallback mechanisms - - Provide clear error messages - - Enable debug information when needed - -2. **Data Validation** - - Verify semester IDs - - Validate semester names - - Check for empty results - - Ensure chronological order - -3. **User Experience** - - Support both automatic and manual selection - - Provide clear selection interface - - Handle cancellation gracefully - - Maintain input buffer cleanliness - -4. **Performance** - - Cache semester data when appropriate - - Minimize network requests - - Optimize HTML parsing - - Handle large datasets efficiently - -## Dependencies -- `goquery`: HTML parsing -- `bufio`: Input handling -- `strings`: String manipulation -- `strconv`: Number conversion -- `fmt`: Formatted I/O -- `os`: System operations - -## Error Handling -- Authentication validation -- Network error management -- Parsing error handling -- Selection validation -- Buffer management - -## Security Considerations -- Validates authentication tokens -- Sanitizes user input -- Handles sensitive data appropriately -- Implements proper access controls - -# General Helpers - -## Overview -The General Helpers module provides a comprehensive suite of utility functions that support core application functionality. These helpers handle everything from string manipulation and file operations to calendar generation and UI formatting, ensuring consistent behavior and robust error handling across the application. - -## Core Components - -### String Manipulation - -#### `StrToInt(str string) int` -Converts string representations of numbers to integers with debug-aware error handling. - -**Implementation Details:** -- Uses `strconv.Atoi` for conversion -- Handles conversion errors gracefully -- Provides debug output when enabled -- Returns 0 for invalid inputs - -#### `TruncateWithEllipsis(s string, maxLength int) string` -Smart string truncation that preserves Unicode characters. - -**Features:** -- Handles multi-byte characters correctly -- Preserves string integrity -- Adds ellipsis when truncated -- Handles edge cases for small lengths - -#### `SanitizeString(input string) string` -Cleanses strings for safe usage in various contexts. - -**Implementation:** -- Removes control characters -- Preserves Unicode letters and numbers -- Maintains whitespace where appropriate -- Handles special characters safely - -### Date and Time Formatting - -#### `FormatDateTime(dateStr string) string` -Flexible date-time parsing and formatting. - -**Supported Formats:** -- "02-Jan-2006 03:04 PM" -- "02-Jan-2006 15:04" -- "02-Jan-2006" -- "02/01/2006" -- "02/01/06" - -**Features:** -- Multi-format parsing -- Consistent output format -- Graceful fallback -- Time zone handling - -### File Operations - -#### `SaveFile(data []byte, filePath string) error` -Secure file saving with proper error handling. - -**Implementation Details:** -- Creates directories if needed -- Handles permissions -- Atomic write operations -- Validates file path - -#### `GetFileExtension(filename string, body []byte, headers http.Header) string` -Smart file extension detection. - -**Detection Methods:** -1. Original filename extension -2. MIME type from headers -3. Content-based detection -4. OOXML detection for Office files - -### Calendar Integration - -#### `GenerateCalendarImportLinks(icsURL string, calendarName string)` -Creates calendar import links for major platforms. - -**Supported Platforms:** -- Google Calendar -- Microsoft Outlook -- Generic ICS import - -**Features:** -- URL encoding -- ANSI-colored output -- Clickable links -- Platform-specific formatting - -### HTTP Operations - -#### `FetchReqClient(client *http.Client, ...) ([]byte, http.Header, error)` -Advanced HTTP request handling. - -Use `helpers.GetHTTPClient()` to obtain the shared client when making requests. - -**Features:** -- Cookie management -- Custom headers -- Multiple HTTP methods -- Response validation -- Error handling - -### UI Formatting - -#### `ColorStatus(status string) string` -Status text coloring for better visibility. - -**Color Coding:** -- Red: Pending/Error states -- Green: Approved/Success states -- Default: Neutral states - -#### `AddLeftPadding(text string, padding int) string` -Consistent text padding for UI elements. - -**Features:** -- Multi-line support -- Preserves string content -- Configurable padding width -- Unicode character support - -## Usage Examples - -```go -// String manipulation -truncated := TruncateWithEllipsis("Long text here", 10) -sanitized := SanitizeString("User input `); got != "token-123" { + t.Fatalf("ExtractCSRF() = %q", got) + } + if got := ExtractCSRF2(``); got != "abc123-def" { + t.Fatalf("ExtractCSRF2() = %q", got) + } + if got, err := ExtractRegNo(``); err != nil || got != "22BCE0001" { + t.Fatalf("ExtractRegNo() = %q, %v", got, err) + } + if got := ExtractImage(`
`); got != "data:image/jpeg;base64,abc" { + t.Fatalf("ExtractImage() = %q", got) + } +} + +func TestExtractCookiesHandlesNilResponse(t *testing.T) { + if got := ExtractCookies(nil); got != (types.Cookies{}) { + t.Fatalf("ExtractCookies(nil) = %#v", got) + } + + response := &http.Response{Header: http.Header{ + "Set-Cookie": {"SERVERID=server; Path=/", "JSESSIONID=session; Path=/"}, + }} + got := ExtractCookies(response) + if got.SERVERID != "server" || got.JSESSIONID != "session" { + t.Fatalf("ExtractCookies() = %#v", got) + } +} diff --git a/helpers/file_extension_test.go b/helpers/file_extension_test.go new file mode 100644 index 00000000..eac69b9d --- /dev/null +++ b/helpers/file_extension_test.go @@ -0,0 +1,50 @@ +package helpers + +import ( + "archive/zip" + "bytes" + "net/http" + "testing" +) + +func TestGetFileExtensionUsesAvailableMetadata(t *testing.T) { + tests := []struct { + name string + filename string + headers http.Header + body []byte + want string + }{ + {name: "filename", filename: "notes.PDF", want: ".PDF"}, + {name: "content disposition", headers: http.Header{"Content-Disposition": {`attachment; filename="marks.xlsx"`}}, want: ".xlsx"}, + {name: "pdf signature", body: []byte("%PDF-1.7\n"), want: ".pdf"}, + {name: "unknown", body: []byte("plain text"), want: ".bin"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetFileExtension(tt.filename, tt.body, tt.headers); got != tt.want { + t.Fatalf("GetFileExtension() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetFileExtensionDetectsOOXMLOnce(t *testing.T) { + var body bytes.Buffer + writer := zip.NewWriter(&body) + file, err := writer.Create("word/document.xml") + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte("")); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + if got := GetFileExtension("download", body.Bytes(), nil); got != ".docx" { + t.Fatalf("GetFileExtension() = %q, want .docx", got) + } +} diff --git a/helpers/formatter.go b/helpers/formatter.go index 9a0ca28d..6093de07 100644 --- a/helpers/formatter.go +++ b/helpers/formatter.go @@ -33,23 +33,3 @@ func FormatBodyData(bodyData map[string]string) string { } return sb.String() } - -func FormatCookies(cookies map[string]string) string { - if len(cookies) == 0 { - return "" - } - var builder strings.Builder - keys := make([]string, 0, len(cookies)) - for key := range cookies { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - builder.WriteString(key) - builder.WriteByte('=') - builder.WriteString(cookies[key]) - builder.WriteString("; ") - } - str := builder.String() - return strings.TrimSuffix(str, "; ") -} diff --git a/helpers/helper.go b/helpers/helper.go index 8b30e31f..444064b0 100644 --- a/helpers/helper.go +++ b/helpers/helper.go @@ -23,21 +23,8 @@ import ( "github.com/h2non/filetype" "github.com/PuerkitoBio/goquery" - //"golang.org/x/net/html" ) -// func GetTextContent(n *html.Node) string { -// var textContent string -// for c := n.FirstChild; c != nil; c = c.NextSibling { -// if c.Type == html.TextNode { -// textContent += c.Data -// } else if c.Type == html.ElementNode { -// textContent += GetTextContent(c) -// } -// } -// return textContent -// } - func StrToInt(str string) int { num, err := strconv.Atoi(str) if err != nil && debug.Debug { @@ -46,20 +33,6 @@ func StrToInt(str string) int { return num } -func FindOptionWithTagValue(doc *goquery.Document, targetValue string) string { - return doc.Find("option[value='" + targetValue + "']").Text() -} - -func RemoveEmptyStrings(data []string) []string { - var cleanedData []string - for _, item := range data { - if item != "" { - cleanedData = append(cleanedData, item) - } - } - return cleanedData -} - func GenerateCalendarImportLinks(icsURL string, calendarName string) { StopHeadlineForOutput() fmt.Println("Import into your calendar using the links below:") @@ -143,15 +116,6 @@ func TruncateWithEllipses(text string, maxLength int) string { return text } -func AddLeftPadding(text string, padding int) string { - paddingString := strings.Repeat(" ", padding) - lines := strings.Split(text, "\n") - for i, line := range lines { - lines[i] = paddingString + line - } - return strings.Join(lines, "\n") -} - func EscapeString(str string) string { str = strings.ReplaceAll(str, "\\", "\\\\") str = strings.ReplaceAll(str, ";", "\\;") @@ -233,39 +197,23 @@ func SanitizeFilename(name string) string { func SaveFile(data []byte, filePath string) error { return os.WriteFile(filePath, data, 0644) } -func FormatBodyDataClient(payloadMap map[string]string) []byte { - if len(payloadMap) == 0 { - return nil - } - var buf bytes.Buffer - first := true - for key, value := range payloadMap { - if !first { - buf.WriteByte('&') - } else { - first = false - } - buf.WriteString(url.QueryEscape(key)) - buf.WriteByte('=') - buf.WriteString(url.QueryEscape(value)) - } - return buf.Bytes() -} - -func FetchReqClient(client *http.Client, regNo string, cookies types.Cookies, url string, referer string, formData []byte, method string, contentType string) ([]byte, http.Header, error) { +func FetchReqClient(client *http.Client, cookies types.Cookies, url string, referer string, formData string, method string, contentType string) ([]byte, http.Header, error) { ctx, cancel := context.WithTimeout(context.Background(), defaultRequestTimeout) defer cancel() - return FetchReqClientWithContext(ctx, client, regNo, cookies, url, referer, formData, method, contentType) + return FetchReqClientWithContext(ctx, client, cookies, url, referer, formData, method, contentType) } -func FetchReqClientWithContext(ctx context.Context, client *http.Client, regNo string, cookies types.Cookies, url string, referer string, formData []byte, method string, contentType string) ([]byte, http.Header, error) { +func FetchReqClientWithContext(ctx context.Context, client *http.Client, cookies types.Cookies, url string, referer string, formData string, method string, contentType string) ([]byte, http.Header, error) { if ctx == nil { ctx = context.Background() } + if client == nil { + client = GetHTTPClient() + } if err := ValidateVtopURL(url); err != nil { return nil, nil, err } - req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(formData)) + req, err := http.NewRequestWithContext(ctx, method, url, strings.NewReader(formData)) if err != nil { return nil, nil, fmt.Errorf("failed to create HTTP request: %w", err) } @@ -303,69 +251,40 @@ func FetchReqClientWithContext(ctx context.Context, client *http.Client, regNo s if err != nil { return nil, nil, fmt.Errorf("failed to read response body: %w", err) } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return body, resp.Header, fmt.Errorf("VTOP request failed with status %s", resp.Status) + } return body, resp.Header, nil } -// func buildCookieHeader(cookies types.Cookies) string { -// return fmt.Sprintf("JSESSIONID=%s; SERVERID=%s;", cookies.JSESSIONID, cookies.SERVERID) -// } - func GetFileExtension(filename string, body []byte, headers http.Header) string { - ext := filepath.Ext(filename) - if ext != "" { + if ext := filepath.Ext(filename); ext != "" { return ext } - contentDisposition := headers.Get("Content-Disposition") - if contentDisposition != "" { + if contentDisposition := headers.Get("Content-Disposition"); contentDisposition != "" { _, params, err := mime.ParseMediaType(contentDisposition) if err == nil { - if cdFilename, ok := params["filename"]; ok && cdFilename != "" { - ext = filepath.Ext(cdFilename) - if ext != "" { - return ext - } + if ext := filepath.Ext(params["filename"]); ext != "" { + return ext } } } + if ext := ooxmlExtension(body); ext != "" { + return ext + } + kind, err := filetypeMatch(body) if err == nil && kind != "unknown" { - fmt.Printf("Filetype package detected: %s\n", kind) switch kind { - case "doc": - return ".doc" - case "xls": - return ".xls" - case "ppt": - return ".ppt" - case "docx": - return ".docx" - case "xlsx": - return ".xlsx" - case "pptx": - return ".pptx" - case "pdf": - return ".pdf" - case "zip": - if isOOXML(body) { - ooxmlExt := getOOXMLExtension(body) - if ooxmlExt != "" { - return ooxmlExt - } - } - return ".zip" - default: - fmt.Printf("Filetype package detected unknown type: %s\n", kind) + case "doc", "xls", "ppt", "docx", "xlsx", "pptx", "pdf", "zip": + return "." + kind } - } else { - fmt.Println("Filetype package could not determine the file type.") } - mimeType := http.DetectContentType(body) - fmt.Printf("MIME type detected: %s\n", mimeType) - switch mimeType { + switch http.DetectContentType(body) { case "application/msword": return ".doc" case "application/vnd.ms-excel": @@ -381,107 +300,44 @@ func GetFileExtension(filename string, body []byte, headers http.Header) string case "application/pdf": return ".pdf" case "application/zip": - if isOOXML(body) { - ooxmlExt := getOOXMLExtension(body) - if ooxmlExt != "" { - return ooxmlExt - } - } return ".zip" - default: - fmt.Printf("Unhandled MIME type: %s\n", mimeType) } if len(body) >= 8 && bytes.Equal(body[:8], []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}) { - fmt.Println("OLE Compound Document detected.") if bytes.Contains(body, []byte("WordDocument")) { - fmt.Println("Identified as .doc") return ".doc" } if bytes.Contains(body, []byte("Workbook")) || bytes.Contains(body, []byte("Book")) { - fmt.Println("Identified as .xls") return ".xls" } if bytes.Contains(body, []byte("PowerPoint Document")) { - fmt.Println("Identified as .ppt") return ".ppt" } - fmt.Println("OLE Compound Document but specific type not identified. Assigning .bin") return ".bin" } if len(body) >= 4 && string(body[:4]) == "PK\x03\x04" { - fmt.Println("ZIP archive detected. Inspecting internal structure for OOXML formats.") - readerAt := bytes.NewReader(body) - size := int64(len(body)) - zipReader, err := zip.NewReader(readerAt, size) - if err == nil { - for _, f := range zipReader.File { - if strings.HasPrefix(f.Name, "ppt/") { - fmt.Println("Identified as .pptx") - return ".pptx" - } else if strings.HasPrefix(f.Name, "word/") { - fmt.Println("Identified as .docx") - return ".docx" - } else if strings.HasPrefix(f.Name, "xl/") { - fmt.Println("Identified as .xlsx") - return ".xlsx" - } - } - } else { - fmt.Printf("Error reading ZIP structure: %v\n", err) - } + return ".zip" } - fmt.Println("Failed to determine file extension; assigning .bin") - return ".bin" -} - -// func urlQueryEscape(s string) string { -// return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") -// } - -// func mimeParseMediaType(v string) (mediatype string, params map[string]string, err error) { -// return mime.ParseMediaType(v) -// } - -// func isOLECompoundDocument(body []byte) bool { -// return len(body) >= 8 && bytes.Equal(body[:8], []byte{0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1}) -// } - -// func bytesContains(body []byte, substr string) bool { -// return bytes.Contains(body, []byte(substr)) -// } - -func isOOXML(body []byte) bool { - readerAt := bytes.NewReader(body) - size := int64(len(body)) - zipReader, err := zip.NewReader(readerAt, size) - if err != nil { - return false - } - for _, f := range zipReader.File { - if strings.HasPrefix(f.Name, "ppt/") || strings.HasPrefix(f.Name, "word/") || strings.HasPrefix(f.Name, "xl/") { - return true - } + if debug.Debug { + fmt.Println("Unable to determine file extension; using .bin") } - return false + return ".bin" } -func getOOXMLExtension(body []byte) string { - readerAt := bytes.NewReader(body) - size := int64(len(body)) - zipReader, err := zip.NewReader(readerAt, size) +func ooxmlExtension(body []byte) string { + zipReader, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) if err != nil { - fmt.Printf("Error reading ZIP structure: %v\n", err) return "" } for _, f := range zipReader.File { - if strings.HasPrefix(f.Name, "ppt/") { + switch { + case strings.HasPrefix(f.Name, "ppt/"): return ".pptx" - } else if strings.HasPrefix(f.Name, "word/") { + case strings.HasPrefix(f.Name, "word/"): return ".docx" - } else if strings.HasPrefix(f.Name, "xl/") { + case strings.HasPrefix(f.Name, "xl/"): return ".xlsx" } } @@ -573,6 +429,9 @@ func GetOrCreateDownloadDir(subDir string) (string, error) { // OpenFolder opens the folder containing the specified path using the system's default file manager func OpenFolder(path string) { + if ShouldMuteUI() { + return + } var cmd *exec.Cmd switch runtime.GOOS { case "windows": @@ -601,12 +460,3 @@ func OpenFolder(path string) { fmt.Printf("Error opening folder: %v\n", err) } } -func ParseFloat(s string) float64 { - f, err := strconv.ParseFloat(strings.TrimSpace(s), 64) - if err != nil { - return 0 - } - return f -} - -var VtopLoginGlobal func() (types.Cookies, string) diff --git a/helpers/http_client.go b/helpers/http_client.go index 78c7fa5a..504ef001 100644 --- a/helpers/http_client.go +++ b/helpers/http_client.go @@ -32,7 +32,6 @@ func init() { ReadBufferSize: 32 * 1024, WriteBufferSize: 32 * 1024, TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, ClientSessionCache: sessionCache, }, } diff --git a/helpers/ics-generator.go b/helpers/ics-generator.go index 9b3f1354..ed3d8abc 100644 --- a/helpers/ics-generator.go +++ b/helpers/ics-generator.go @@ -2,6 +2,7 @@ package helpers import ( "bytes" + "context" "crypto/rand" "encoding/hex" "encoding/json" @@ -114,14 +115,15 @@ func UploadICSFile(filePath string, serverURL string) (string, error) { uploadURL := serverURL + "/upload" - req, err := http.NewRequest("POST", uploadURL, &requestBody) + ctx, cancel := context.WithTimeout(context.Background(), defaultRequestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, &requestBody) if err != nil { return "", fmt.Errorf("failed to create POST request: %v", err) } req.Header.Set("Content-Type", writer.FormDataContentType()) - client := &http.Client{} - resp, err := client.Do(req) + resp, err := GetHTTPClient().Do(req) if err != nil { return "", fmt.Errorf("failed to upload ICS file: %v", err) } diff --git a/helpers/selection.go b/helpers/selection.go index 49efcd32..056c1554 100644 --- a/helpers/selection.go +++ b/helpers/selection.go @@ -1,57 +1,21 @@ package helpers import ( - "cli-top/debug" "cli-top/types" - "fmt" "regexp" "sort" - "strconv" "strings" - // "github.com/olekukonko/tablewriter" ) var ( - courseCodePrefixRegex = regexp.MustCompile(`^[A-Z]{4}\d{3}[A-Z]?\s*[─-]\s*`) - facultyERPIDRegex = regexp.MustCompile(`^\d+\s*[─–—-]\s*`) - splitNameRegex = regexp.MustCompile(`\s*[─–—-]\s*`) + facultyERPIDRegex = regexp.MustCompile(`^\d+\s*[─–—-]\s*`) + splitNameRegex = regexp.MustCompile(`\s*[─–—-]\s*`) ) -func RemoveCourseCode(courseName string) string { - return courseCodePrefixRegex.ReplaceAllString(courseName, "") -} - -func TruncateString(str string, maxLength int) string { - if len(str) <= maxLength { - return str - } - if maxLength <= 3 { - return str[:maxLength] - } - return str[:maxLength-3] + "..." -} - -func HighlightMatches(text, query string) string { - re := regexp.MustCompile("(?i)" + regexp.QuoteMeta(query)) - return re.ReplaceAllStringFunc(text, func(match string) string { - return "\033[1m" + match + "\033[0m" - }) -} - func RedactERPID(facultyName string) string { return facultyERPIDRegex.ReplaceAllString(facultyName, "") } -func SplitCourseName(courseName string) (string, string) { - idx := splitNameRegex.FindStringIndex(courseName) - if idx != nil { - courseCode := strings.TrimSpace(courseName[:idx[0]]) - courseNamePart := strings.TrimSpace(courseName[idx[1]:]) - return courseCode, courseNamePart - } - return courseName, "" -} - func SplitCourseNameFull(courseName string) []string { parts := splitNameRegex.Split(courseName, -1) for i := range parts { @@ -72,169 +36,6 @@ func ReplaceCrossWithPlus(input string) string { return strings.ReplaceAll(input, "┼", "+") } -// func GenerateFacultyDetailsTable(faculties []types.Faculty, query string) { -// var buf bytes.Buffer -// table := tablewriter.NewWriter(&buf) - -// table.SetHeader([]string{"INDEX", "SLOT", "NAME"}) - -// table.SetBorder(false) -// table.SetHeaderLine(true) -// table.SetRowLine(false) -// table.SetAutoWrapText(false) -// table.SetAlignment(tablewriter.ALIGN_LEFT) -// table.SetColumnSeparator("│") - -// for i, faculty := range faculties { -// index := fmt.Sprintf("%5d", i+1) -// slot := ReplaceCrossWithPlus(faculty.Slot) - -// name := RedactERPID(faculty.Name) - -// if query != "" { -// name = HighlightMatches(name, query) -// } - -// table.Append([]string{index, slot, name}) -// } - -// table.Render() -// output := buf.String() -// output = strings.ReplaceAll(output, "-", "─") -// output = strings.ReplaceAll(output, "|", "│") - -// output = AddLeftPadding(output, 2) - -// fmt.Print(output) -// fmt.Println() -// } - -// func GenerateCourseDetailsTable(courses []types.Course) { -// var buf bytes.Buffer -// table := tablewriter.NewWriter(&buf) - -// table.SetHeader([]string{"INDEX", "COURSE CODE", "COURSE NAME"}) - -// table.SetBorder(false) -// table.SetHeaderLine(true) -// table.SetRowLine(false) -// table.SetAutoWrapText(false) -// table.SetAlignment(tablewriter.ALIGN_LEFT) -// table.SetColumnSeparator("│") - -// for i, course := range courses { -// index := fmt.Sprintf("%5d", i+1) -// courseCode, courseName := SplitCourseName(course.Name) - -// courseCode = ReplaceCrossWithPlus(courseCode) - -// table.Append([]string{index, courseCode, courseName}) -// } - -// table.Render() -// output := buf.String() -// output = strings.ReplaceAll(output, "-", "─") -// output = strings.ReplaceAll(output, "|", "│") - -// output = AddLeftPadding(output, 2) - -// fmt.Print(output) -// fmt.Println() -// } - -// func SelectFaculty(faculties []types.Faculty, facultyFlag int) (types.Faculty, error) { -// if len(faculties) == 0 { -// return types.Faculty{}, fmt.Errorf("no faculties available for selection") -// } - -// if facultyFlag > 0 && facultyFlag <= len(faculties) { -// return faculties[facultyFlag-1], nil -// } - -// if len(faculties) <= 15 { -// GenerateFacultyDetailsTable(faculties, "") -// fmt.Println() -// fmt.Print("Select a Faculty by entering the number: ") -// var index int -// _, err := fmt.Scanln(&index) -// if err != nil { -// if debug.Debug { -// fmt.Println("Invalid input for faculty selection:", err) -// } -// return types.Faculty{}, fmt.Errorf("invalid input for faculty selection") -// } -// if index < 1 || index > len(faculties) { -// fmt.Println("Invalid selection. Please enter a valid number.") -// return types.Faculty{}, fmt.Errorf("invalid faculty selection") -// } -// return faculties[index-1], nil -// } - -// for { -// fmt.Print("\nEnter search query (or press Enter to list all, type 'exit' to cancel): ") -// var query string -// _, err := fmt.Scanln(&query) -// if err != nil { -// if debug.Debug { -// fmt.Println("Error reading input:", err) -// } -// return types.Faculty{}, fmt.Errorf("error reading input") -// } - -// query = strings.TrimSpace(query) - -// if strings.ToLower(query) == "exit" { -// fmt.Println("Operation cancelled by user.") -// return types.Faculty{}, fmt.Errorf("selection cancelled") -// } - -// var displayFaculties []types.Faculty -// if query != "" { -// for _, faculty := range faculties { -// if FuzzyMatch(query, faculty.Name) { -// displayFaculties = append(displayFaculties, faculty) -// } -// } -// if len(displayFaculties) == 0 { -// fmt.Println("No faculties matched your search. Try again.") -// continue -// } -// } else { -// displayFaculties = faculties -// } - -// GenerateFacultyDetailsTable(displayFaculties, query) -// fmt.Println() -// fmt.Print("Enter the number of the faculty to select (or type 's' to search again, 'exit' to cancel): ") -// var selection string -// _, err = fmt.Scanln(&selection) -// if err != nil { -// if debug.Debug { -// fmt.Println("Error reading selection:", err) -// } -// return types.Faculty{}, fmt.Errorf("error reading selection") -// } - -// selection = strings.TrimSpace(selection) - -// if strings.ToLower(selection) == "s" { -// continue -// } -// if strings.ToLower(selection) == "exit" { -// fmt.Println("Operation cancelled by user.") -// return types.Faculty{}, fmt.Errorf("selection cancelled") -// } - -// index, err := strconv.Atoi(selection) -// if err != nil || index < 1 || index > len(displayFaculties) { -// fmt.Println("Invalid selection. Please enter a valid number.") -// continue -// } - -// return displayFaculties[index-1], nil -// } -// } - func RemoveDuplicateFaculties(faculties []types.FacultyOld) []types.FacultyOld { uniqueFaculties := make([]types.FacultyOld, 0, len(faculties)) keys := make(map[string]bool) @@ -253,94 +54,3 @@ func SortFacultiesAlphabetically(faculties []types.FacultyOld) { return strings.ToLower(faculties[i].Name) < strings.ToLower(faculties[j].Name) }) } - -// func GenerateCourseMaterialsTable(materials []types.CourseMaterial) { -// var buf bytes.Buffer -// table := tablewriter.NewWriter(&buf) - -// table.SetHeader([]string{"INDEX", "DATE", "DAY ORDER/SLOT", "TOPIC", "REF MATERIALS"}) - -// table.SetBorder(false) -// table.SetHeaderLine(true) -// table.SetRowLine(false) -// table.SetAutoWrapText(false) -// table.SetAlignment(tablewriter.ALIGN_LEFT) -// table.SetColumnSeparator("│") - -// for _, material := range materials { -// index := fmt.Sprintf("%5d", material.Index) -// date := material.Date -// dayOrderSlot := ReplaceCrossWithPlus(material.DayOrderSlot) -// topic := TruncateString(material.Topic, 40) -// refMaterialsCount := fmt.Sprintf("%d", len(material.ReferenceMaterials)) -// table.Append([]string{index, date, dayOrderSlot, topic, refMaterialsCount}) -// } - -// table.Render() -// output := buf.String() -// output = strings.ReplaceAll(output, "-", "─") -// output = strings.ReplaceAll(output, "|", "│") - -// output = AddLeftPadding(output, 2) - -// fmt.Print(output) -// fmt.Println() -// } - -func SelectCourseMaterials(materials []types.CourseMaterial) ([]types.CourseMaterial, error) { - for { - fmt.Print("Enter the index numbers of the topics to download (e.g., 1,2,3), or 0 for bulk download: ") - var input string - _, err := fmt.Scanln(&input) - if err != nil { - if debug.Debug { - fmt.Println("Error reading input:", err) - } - return nil, err - } - - input = strings.TrimSpace(input) - - if input == "" { - fmt.Println("No input provided.") - continue - } - - if input == "0" { - return materials, nil - } - - indicesStr := strings.Split(input, ",") - indexSet := make(map[int]struct{}) - var invalidIndices []string - for _, idxStr := range indicesStr { - idxStr = strings.TrimSpace(idxStr) - idx, err := strconv.Atoi(idxStr) - if err != nil { - invalidIndices = append(invalidIndices, idxStr) - continue - } - if idx < 1 || idx > len(materials) { - invalidIndices = append(invalidIndices, idxStr) - continue - } - indexSet[idx] = struct{}{} - } - - if len(invalidIndices) > 0 { - fmt.Println("Invalid indices:", strings.Join(invalidIndices, ", ")) - } - - if len(indexSet) == 0 { - fmt.Println("No valid indices selected.") - continue - } - - var selectedMaterials []types.CourseMaterial - for idx := range indexSet { - selectedMaterials = append(selectedMaterials, materials[idx-1]) - } - - return selectedMaterials, nil - } -} diff --git a/helpers/sem-details.go b/helpers/sem-details.go index 89e93502..1134eb92 100644 --- a/helpers/sem-details.go +++ b/helpers/sem-details.go @@ -1,7 +1,6 @@ package helpers import ( - "bufio" "bytes" "cli-top/debug" "cli-top/types" @@ -10,129 +9,42 @@ import ( "strconv" "strings" - "golang.org/x/net/html" + "github.com/PuerkitoBio/goquery" ) -type selectCandidate struct { - id string - name string - class string - options []types.Semester -} - -// Initialize a single reader instance for the package -var reader = bufio.NewReader(os.Stdin) - func FindAndSaveSemIds(body []byte) ([]types.Semester, error) { - var ( - candidates []selectCandidate - currentSel *selectCandidate - inOption bool - optionVal string - textBuf strings.Builder - ) - - z := html.NewTokenizer(bytes.NewReader(body)) - for { - switch z.Next() { - case html.ErrorToken: - if len(candidates) == 0 { - if debug.Debug { - fmt.Println("No semesters found in document.") - } - return nil, fmt.Errorf("no semesters found") - } - chosen := pickSemesterSelect(candidates) - if len(chosen.options) == 0 { - return nil, fmt.Errorf("no semesters found") - } - return chosen.options, nil - case html.StartTagToken, html.SelfClosingTagToken: - tagName, hasAttr := z.TagName() - switch string(tagName) { - case "select": - if currentSel != nil { - candidates = append(candidates, *currentSel) - } - currentSel = &selectCandidate{} - if hasAttr { - for { - key, val, more := z.TagAttr() - switch string(key) { - case "id": - currentSel.id = string(val) - case "name": - currentSel.name = string(val) - case "class": - currentSel.class = string(val) - } - if !more { - break - } - } - } - case "option": - if currentSel == nil { - break - } - inOption = true - optionVal = "" - textBuf.Reset() - if hasAttr { - for { - key, val, more := z.TagAttr() - if string(key) == "value" { - optionVal = string(val) - } - if !more { - break - } - } - } - } - case html.TextToken: - if inOption { - textBuf.Write(z.Text()) - } - case html.EndTagToken: - tagName, _ := z.TagName() - switch string(tagName) { - case "option": - if inOption && currentSel != nil { - text := strings.TrimSpace(textBuf.String()) - if optionVal != "" && text != "" { - currentSel.options = append(currentSel.options, types.Semester{SemID: optionVal, SemName: text}) - } - } - inOption = false - case "select": - if currentSel != nil { - candidates = append(candidates, *currentSel) - currentSel = nil + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("parse semester page: %w", err) + } + + selectors := []string{ + "select[id*='semesterSubId']", + "select[name*='semesterSubId']", + "select.form-select", + "select", + } + for _, selector := range selectors { + var semesters []types.Semester + doc.Find(selector).EachWithBreak(func(_ int, selection *goquery.Selection) bool { + selection.Find("option").Each(func(_ int, option *goquery.Selection) { + id := strings.TrimSpace(option.AttrOr("value", "")) + name := strings.TrimSpace(option.Text()) + if id != "" && name != "" { + semesters = append(semesters, types.Semester{SemID: id, SemName: name}) } - } + }) + return len(semesters) == 0 + }) + if len(semesters) > 0 { + return semesters, nil } } -} -func pickSemesterSelect(candidates []selectCandidate) selectCandidate { - for _, c := range candidates { - if len(c.options) == 0 { - continue - } - if strings.Contains(c.id, "semesterSubId") || strings.Contains(c.name, "semesterSubId") { - return c - } - if strings.Contains(c.class, "form-select") { - return c - } - } - for _, c := range candidates { - if len(c.options) > 0 { - return c - } + if debug.Debug { + fmt.Println("No semesters found in document.") } - return selectCandidate{} + return nil, fmt.Errorf("no semesters found") } // GetSemDetails fetches semester details @@ -143,61 +55,37 @@ func GetSemDetails(cookies types.Cookies, regNo string) ([]types.Semester, error if cookies.CSRF == "" || cookies.JSESSIONID == "" || cookies.SERVERID == "" { return nil, fmt.Errorf("please login first using the cli-top login command") } - url := "https://vtop.vit.ac.in/vtop/academics/common/StudentAttendance" - var allSems []types.Semester - bodyText, err := FetchReq(regNo, cookies, url, "", "", "POST", "") - if err != nil { - if debug.Debug { - fmt.Println("Error fetching semester details", err) - } - return allSems, err + endpoints := []string{ + "https://vtop.vit.ac.in/vtop/academics/common/StudentAttendance", + "https://vtop.vit.ac.in/vtop/academics/common/StudentCoursePage", } - - allSems, err = FindAndSaveSemIds(bodyText) - if err != nil { - if debug.Debug { - fmt.Println("Error fetching semester details", err) + var lastErr error + for _, endpoint := range endpoints { + body, err := FetchReq(regNo, cookies, endpoint, "", "", "POST", "") + if err != nil { + lastErr = err + continue } - return allSems, err - } - ReverseSlice(allSems) - storeSemesters(regNo, allSems) - return allSems, nil -} - -func GetSemDetailsBackup(cookies types.Cookies, regNo string) ([]types.Semester, error) { - if cached, ok := getCachedSemesters(regNo); ok { - return cached, nil - } - url := "https://vtop.vit.ac.in/vtop/academics/common/StudentCoursePage" - var allSems []types.Semester - bodyText, err := FetchReq(regNo, cookies, url, "", "", "POST", "") - if err != nil { - return allSems, err + semesters, err := FindAndSaveSemIds(body) + if err != nil { + lastErr = err + continue + } + ReverseSlice(semesters) + storeSemesters(regNo, semesters) + return semesters, nil } - allSems, err = FindAndSaveSemIds(bodyText) - if err != nil { - return allSems, err + if debug.Debug && lastErr != nil { + fmt.Println("Error fetching semester details:", lastErr) } - ReverseSlice(allSems) - storeSemesters(regNo, allSems) - return allSems, nil + return nil, fmt.Errorf("fetch semester details: %w", lastErr) } func SelectSemester(regNo string, cookies types.Cookies, sem_choice int) (types.Semester, error) { semDetails, err := GetSemDetails(cookies, regNo) var selectedSem types.Semester if err != nil { - if debug.Debug { - fmt.Println("Error featching sem details:", err) - } - semDetails, err = GetSemDetailsBackup(cookies, regNo) - if err != nil { - if debug.Debug { - fmt.Println("Error fetching semester details in backup", err) - } - return selectedSem, err - } + return selectedSem, err } if len(semDetails) == 0 { if debug.Debug { @@ -226,27 +114,5 @@ func SelectSemester(regNo string, cookies types.Cookies, sem_choice int) (types. } selectedSem = semDetails[choice.Index-1] - _ = clearInputBuffer() return selectedSem, nil } - -func clearInputBuffer() error { - for { - - if reader.Buffered() == 0 { - return nil - } - - b, err := reader.ReadByte() - if err != nil { - if debug.Debug { - fmt.Println("Error clearing input buffer:", err) - } - return err - } - - if b == '\n' { - return nil - } - } -} diff --git a/helpers/sem_details_test.go b/helpers/sem_details_test.go new file mode 100644 index 00000000..63703e0e --- /dev/null +++ b/helpers/sem_details_test.go @@ -0,0 +1,36 @@ +package helpers + +import ( + "reflect" + "testing" + + "cli-top/types" +) + +func TestFindAndSaveSemIDsPrefersSemesterSelect(t *testing.T) { + body := []byte(` + + `) + + got, err := FindAndSaveSemIds(body) + if err != nil { + t.Fatalf("FindAndSaveSemIds() error = %v", err) + } + want := []types.Semester{ + {SemID: "SEM1", SemName: "Fall Semester 2025-26"}, + {SemID: "SEM2", SemName: "Winter Semester 2025-26"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("FindAndSaveSemIds() = %#v, want %#v", got, want) + } +} + +func TestFindAndSaveSemIDsRejectsMissingOptions(t *testing.T) { + if _, err := FindAndSaveSemIds([]byte(``)); err == nil { + t.Fatal("FindAndSaveSemIds() expected an error") + } +} diff --git a/helpers/table-renderer.go b/helpers/table-renderer.go index c5b2d525..a12b80a6 100644 --- a/helpers/table-renderer.go +++ b/helpers/table-renderer.go @@ -404,15 +404,11 @@ func TableSelectorFuzzy(subject string, nestedList [][]string, initialQuery stri } } -func PrintTable(nestedList [][]string, indexStatus int) int { +func PrintTable(nestedList [][]string, indexStatus int) { StopHeadlineForOutput() if len(nestedList) == 0 { - fmt.Println("Ummm are you sure you are printing the right thing?") - return 1 - } - - for i, v := range nestedList[0] { - nestedList[0][i] = strings.ToUpper(v) + fmt.Println("No data to display.") + return } maxCols := len(nestedList[0]) @@ -422,6 +418,9 @@ func PrintTable(nestedList [][]string, indexStatus int) int { copy(normalizedRow, row) normalizedList = append(normalizedList, normalizedRow) } + for i, header := range normalizedList[0] { + normalizedList[0][i] = strings.ToUpper(header) + } if indexStatus == 1 { normalizedList[0] = append([]string{"INDEX"}, normalizedList[0]...) @@ -432,7 +431,7 @@ func PrintTable(nestedList [][]string, indexStatus int) int { emitTableSnapshot(sanitizeTableData(normalizedList), indexStatus) if ShouldMuteUI() { - return 0 + return } colWidths := make([]int, len(normalizedList[0])) @@ -521,7 +520,6 @@ func PrintTable(nestedList [][]string, indexStatus int) int { } } - return 0 } func NewFuzzySearch(nestedList [][]string, stringFlag string) []int { @@ -648,25 +646,6 @@ func wrapLine(line string, width int) []string { return lines } -func splitLongWord(word string, width int) []string { - if width <= 0 { - return []string{word} - } - runes := []rune(word) - if len(runes) == 0 { - return []string{""} - } - var parts []string - for start := 0; start < len(runes); start += width { - end := start + width - if end > len(runes) { - end = len(runes) - } - parts = append(parts, string(runes[start:end])) - } - return parts -} - func visibleLen(s string) int { return utf8.RuneCountInString(StripAnsiCodes(s)) } diff --git a/helpers/table_renderer_test.go b/helpers/table_renderer_test.go new file mode 100644 index 00000000..686ed5b3 --- /dev/null +++ b/helpers/table_renderer_test.go @@ -0,0 +1,25 @@ +package helpers + +import ( + "reflect" + "testing" +) + +func TestPrintTableDoesNotMutateInput(t *testing.T) { + t.Setenv("CLI_TOP_PROXY_MODE", "1") + table := [][]string{{"Name", "Status"}, {"Example", "Ready"}} + original := cloneTableData(table) + + var captured TableSnapshot + restore := RegisterTableCaptureHook(func(snapshot TableSnapshot) { captured = snapshot }) + defer restore() + + PrintTable(table, 1) + + if !reflect.DeepEqual(table, original) { + t.Fatalf("PrintTable mutated input: got %#v want %#v", table, original) + } + if !reflect.DeepEqual(captured.Headers, []string{"INDEX", "NAME", "STATUS"}) { + t.Fatalf("captured headers = %#v", captured.Headers) + } +} diff --git a/helpers/ux.go b/helpers/ux.go index 4a080d8f..4fd15b36 100644 --- a/helpers/ux.go +++ b/helpers/ux.go @@ -34,9 +34,6 @@ const ( var ( activeHeadlineMu sync.Mutex activeHeadlineStop func() - - headlineNewlineMu sync.Mutex - headlineNeedsNewline bool ) func ShouldMuteUI() bool { @@ -60,7 +57,6 @@ func CommandRunner(label string, run func(cmd *cobra.Command, args []string)) fu if !muted { stopActiveHeadlineAnimation() - flushHeadlineNewline() elapsed := time.Since(start).Round(10 * time.Millisecond) successLine := fmt.Sprintf("✓ %s (%s)", strings.ToUpper(label), elapsed) color.New(color.FgHiGreen).Printf("\n%s\n\n", successLine) @@ -85,7 +81,6 @@ func CommandRunnerE(label string, run func(cmd *cobra.Command, args []string) er if !muted { stopActiveHeadlineAnimation() - flushHeadlineNewline() elapsed := time.Since(start).Round(10 * time.Millisecond) if err == nil { successLine := fmt.Sprintf("✓ %s (%s)", strings.ToUpper(label), elapsed) @@ -100,14 +95,6 @@ func CommandRunnerE(label string, run func(cmd *cobra.Command, args []string) er } } -func Infof(format string, args ...any) { - if ShouldMuteUI() { - return - } - StopHeadlineForOutput() - fmt.Printf(format, args...) -} - func startOrPrintHeadline(label string) { if stopHeadline := startHeadlineAnimation(label); stopHeadline != nil { setActiveHeadlineAnimation(stopHeadline) @@ -211,13 +198,15 @@ func startHeadlineAnimation(label string) func() { if ShouldMuteUI() { return nil } - supports := terminalSupportsColor() - if !supports { + return startHeadlineAnimationWithSupport(label, terminalSupportsColor()) +} + +func startHeadlineAnimationWithSupport(label string, supportsColor bool) func() { + if !supportsColor { return nil } - initial := renderHeadlineWithSupport(label, supports) - clearHeadlineNewlineFlag() + initial := renderHeadlineWithSupport(label, supportsColor) fmt.Fprintf(color.Output, "\n%s\n\n", initial) done := make(chan struct{}) @@ -230,7 +219,7 @@ func startHeadlineAnimation(label string) func() { for { select { case <-ticker.C: - redrawHeadlineAboveCursor(renderHeadlineWithSupport(label, supports)) + redrawHeadlineAboveCursor(renderHeadlineWithSupport(label, supportsColor)) case <-done: ticker.Stop() return @@ -242,8 +231,7 @@ func startHeadlineAnimation(label string) func() { once.Do(func() { close(done) <-stopped - redrawHeadlineAboveCursor(renderCompletedHeadlineWithSupport(label, supports)) - clearHeadlineNewlineFlag() + redrawHeadlineAboveCursor(renderCompletedHeadlineWithSupport(label, supportsColor)) }) } } @@ -270,34 +258,8 @@ func stopActiveHeadlineAnimation() { func StopHeadlineForOutput() { stopActiveHeadlineAnimation() - flushHeadlineNewline() } func cleanupHeadlineAnimation() { stopActiveHeadlineAnimation() - flushHeadlineNewline() -} - -func markHeadlineNeedsNewline() { - headlineNewlineMu.Lock() - headlineNeedsNewline = true - headlineNewlineMu.Unlock() -} - -func clearHeadlineNewlineFlag() { - headlineNewlineMu.Lock() - headlineNeedsNewline = false - headlineNewlineMu.Unlock() -} - -func flushHeadlineNewline() { - headlineNewlineMu.Lock() - needed := headlineNeedsNewline - if needed { - headlineNeedsNewline = false - } - headlineNewlineMu.Unlock() - if needed { - fmt.Fprint(color.Output, "\r\n") - } } diff --git a/helpers/ux_test.go b/helpers/ux_test.go new file mode 100644 index 00000000..7789be39 --- /dev/null +++ b/helpers/ux_test.go @@ -0,0 +1,83 @@ +package helpers + +import ( + "bytes" + "strings" + "sync" + "testing" + "time" + + "github.com/fatih/color" +) + +type headlineCapture struct { + mu sync.Mutex + output strings.Builder + redrawn chan struct{} + redrawOnce sync.Once +} + +func newHeadlineCapture() *headlineCapture { + return &headlineCapture{redrawn: make(chan struct{})} +} + +func (capture *headlineCapture) Write(data []byte) (int, error) { + capture.mu.Lock() + _, _ = capture.output.Write(data) + capture.mu.Unlock() + if bytes.Contains(data, []byte(ansiUp)) { + capture.redrawOnce.Do(func() { close(capture.redrawn) }) + } + return len(data), nil +} + +func (capture *headlineCapture) String() string { + capture.mu.Lock() + defer capture.mu.Unlock() + return capture.output.String() +} + +func TestHeadlineShimmerRemainsEnabledForColorTerminals(t *testing.T) { + rendered := renderHeadlineWithSupport("marks", true) + if !strings.Contains(rendered, "\x1b[") { + t.Fatalf("color headline has no shimmer formatting: %q", rendered) + } + if got := StripAnsiCodes(rendered); got != "→ MARKS" { + t.Fatalf("rendered headline text = %q", got) + } +} + +func TestHeadlineFallsBackToPlainText(t *testing.T) { + if got := renderHeadlineWithSupport("marks", false); got != "→ MARKS" { + t.Fatalf("plain headline = %q", got) + } +} + +func TestHeadlineAnimationRedrawsAndStopsOnColorTerminals(t *testing.T) { + capture := newHeadlineCapture() + previousOutput := color.Output + color.Output = capture + t.Cleanup(func() { color.Output = previousOutput }) + + stop := startHeadlineAnimationWithSupport("marks", true) + if stop == nil { + t.Fatal("color terminal did not start the headline animation") + } + + select { + case <-capture.redrawn: + case <-time.After(time.Second): + stop() + t.Fatal("headline animation did not redraw") + } + + stop() + stop() + output := capture.String() + if !strings.Contains(output, ansiUp) { + t.Fatalf("animation output has no cursor redraw: %q", output) + } + if !strings.Contains(output, ansiBold+"→ MARKS"+ansiReset) { + t.Fatalf("animation output has no completed headline: %q", output) + } +} diff --git a/internal/nightsliputil/nightsliputil.go b/internal/nightsliputil/nightsliputil.go index 4e735648..14f2ba98 100644 --- a/internal/nightsliputil/nightsliputil.go +++ b/internal/nightsliputil/nightsliputil.go @@ -146,7 +146,7 @@ func PromptOptionSelection(reader *bufio.Reader, options []FormOption, label str helpers.Println() if maxVisible < len(filteredOptions) { - helpers.Printf("Type `m` to show %d more.\n", minInt(selectionPageSize, len(filteredOptions)-maxVisible)) + helpers.Printf("Type `m` to show %d more.\n", min(selectionPageSize, len(filteredOptions)-maxVisible)) } } @@ -431,13 +431,6 @@ func buildOptionSearchTable(options []FormOption) [][]string { return table } -func minInt(first int, second int) int { - if first < second { - return first - } - return second -} - func isCancelInput(input string) bool { switch strings.ToLower(strings.TrimSpace(input)) { case "exit", "cancel": diff --git a/internal/proxyutil/proxyutil.go b/internal/proxyutil/proxyutil.go index d232a2c6..a753ab94 100644 --- a/internal/proxyutil/proxyutil.go +++ b/internal/proxyutil/proxyutil.go @@ -104,16 +104,16 @@ func canonicalFlagName(flag string) string { return "facility" case "g", "class-group", "classgroup": return "classGroup" - case "i", "fuzzy-index", "fuzzyindex": - return "fuzzyIndex" case "d", "debug": return "debug" case "x", "commands", "sync", "sync-commands": return "commands" case "apply": return "apply" - case "details", "reason": + case "details": return "details" + case "reason": + return "reason" case "materials", "material", "topics", "topic-selection": return "materials" case "assignment", "assignments", "da-selection": @@ -134,6 +134,10 @@ func canonicalFlagName(flag string) string { return "applied-to" case "room-type-id", "roomtypeid": return "room-type-id" + case "building-id", "buildingid": + return "building-id" + case "venue": + return "venue" case "event-id", "eventid", "late-hour-event-id", "latehoureventid": return "event-id" default: diff --git a/login/http_test.go b/login/http_test.go new file mode 100644 index 00000000..cdf982c6 --- /dev/null +++ b/login/http_test.go @@ -0,0 +1,141 @@ +package login + +import ( + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "cli-top/helpers" + "cli-top/types" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func response(req *http.Request, statusCode int, status string, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Status: status, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + } +} + +func replaceSharedClientState(t *testing.T, transport http.RoundTripper, checkRedirect func(*http.Request, []*http.Request) error) { + t.Helper() + client := helpers.GetHTTPClient() + oldTransport := client.Transport + oldCheckRedirect := client.CheckRedirect + client.Transport = transport + client.CheckRedirect = checkRedirect + t.Cleanup(func() { + client.Transport = oldTransport + client.CheckRedirect = oldCheckRedirect + }) +} + +func TestPerformLoginCopiesClientBeforeChangingRedirectPolicy(t *testing.T) { + sentinel := errors.New("shared redirect policy") + var loginForm url.Values + replaceSharedClientState(t, roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/vtop/login": + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("read login body: %v", err) + } + loginForm, err = url.ParseQuery(string(body)) + if err != nil { + t.Fatalf("parse login body: %v", err) + } + resp := response(req, http.StatusFound, "302 Found", "") + resp.Header.Set("Location", "https://vtop.vit.ac.in/vtop/redirected") + resp.Header.Add("Set-Cookie", "JSESSIONID=logged-in; Path=/") + return resp, nil + case "/vtop/login/error": + return response(req, http.StatusOK, "200 OK", ""), nil + default: + t.Fatalf("unexpected request path %q", req.URL.Path) + return nil, errors.New("unexpected request") + } + }), func(*http.Request, []*http.Request) error { return sentinel }) + + tokens, errType := performLogin( + types.LogIn{Username: "student", Password: "p&=word"}, + types.Cookies{CSRF: "csrf", JSESSIONID: "session", SERVERID: "server"}, + "123abc", + ) + if errType != "" { + t.Fatalf("performLogin error type = %q", errType) + } + if tokens.JSESSIONID != "logged-in" { + t.Errorf("JSESSIONID = %q, want logged-in", tokens.JSESSIONID) + } + if got := loginForm.Get("password"); got != "p&=word" { + t.Errorf("encoded password = %q, want p&=word", got) + } + + sharedPolicy := helpers.GetHTTPClient().CheckRedirect + if sharedPolicy == nil { + t.Fatal("performLogin cleared the shared redirect policy") + } + if err := sharedPolicy(nil, nil); !errors.Is(err, sentinel) { + t.Fatalf("shared redirect policy returned %v, want %v", err, sentinel) + } +} + +func TestGetLoginPageBoundsMissingCaptchaRetries(t *testing.T) { + rootRequests := 0 + preloginRequests := 0 + replaceSharedClientState(t, roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/": + rootRequests++ + resp := response(req, http.StatusOK, "200 OK", "") + resp.Header.Add("Set-Cookie", "JSESSIONID=session; Path=/") + resp.Header.Add("Set-Cookie", "SERVERID=server; Path=/") + return resp, nil + case "/vtop/prelogin/setup": + preloginRequests++ + return response(req, http.StatusOK, "200 OK", ""), nil + default: + return nil, errors.New("unexpected request") + } + }), nil) + + _, _, err := getLoginPage() + if err == nil || !strings.Contains(err.Error(), "after 3 attempts") { + t.Fatalf("getLoginPage error = %v, want bounded retry error", err) + } + if rootRequests != 3 || preloginRequests != 3 { + t.Fatalf("request counts = root:%d prelogin:%d, want 3 each", rootRequests, preloginRequests) + } +} + +func TestSessionRequestReturnsReadError(t *testing.T) { + wantErr := errors.New("read failed") + replaceSharedClientState(t, roundTripFunc(func(req *http.Request) (*http.Response, error) { + resp := response(req, http.StatusOK, "200 OK", "") + resp.Body = errorReadCloser{err: wantErr} + return resp, nil + }), nil) + + _, err := getSessionServer() + if !errors.Is(err, wantErr) { + t.Fatalf("getSessionServer error = %v, want %v", err, wantErr) + } +} + +type errorReadCloser struct { + err error +} + +func (r errorReadCloser) Read([]byte) (int, error) { return 0, r.err } +func (errorReadCloser) Close() error { return nil } diff --git a/login/login.go b/login/login.go index ae8e1d71..d40548da 100644 --- a/login/login.go +++ b/login/login.go @@ -7,22 +7,29 @@ import ( "fmt" "io" "net/http" - "os" + "net/url" "strings" ) // performLogin executes a single login attempt and returns any high-level error type. // errorType is one of: "", "invalid_captcha", "invalid_credentials", "max_attempts", "network_error". func performLogin(userInfo types.LogIn, cookies types.Cookies, captcha string) (types.Cookies, string) { - client := helpers.GetHTTPClient() + client := *helpers.GetHTTPClient() client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } - defer func() { client.CheckRedirect = nil }() - var data = strings.NewReader(fmt.Sprintf(`_csrf=%s&username=%s&password=%s&captchaStr=%s`, cookies.CSRF, userInfo.Username, userInfo.Password, captcha)) + data := strings.NewReader(url.Values{ + "_csrf": {cookies.CSRF}, + "username": {userInfo.Username}, + "password": {userInfo.Password}, + "captchaStr": {captcha}, + }.Encode()) req, err := http.NewRequest("POST", "https://vtop.vit.ac.in/vtop/login", data) - if err != nil && debug.Debug { - helpers.Println(err) + if err != nil { + if debug.Debug { + helpers.Println(err) + } + return types.Cookies{}, "network_error" } helpers.SetVtopHeaders(req) req.Header.Set("Cache-Control", "max-age=0") @@ -39,6 +46,12 @@ func performLogin(userInfo types.LogIn, cookies types.Cookies, captcha string) ( return types.Cookies{}, "network_error" } defer resp.Body.Close() + if resp.StatusCode >= http.StatusBadRequest { + if debug.Debug { + helpers.Printf("VTOP login request failed with status %s\n", resp.Status) + } + return types.Cookies{}, "network_error" + } if errType := errorCheck(cookies); errType != "" { return types.Cookies{}, errType @@ -50,25 +63,40 @@ func performLogin(userInfo types.LogIn, cookies types.Cookies, captcha string) ( } // errorCheck inspects the login/error page to classify failures. -// Returns one of: "", "invalid_captcha", "invalid_credentials", "max_attempts". +// Returns one of: "", "invalid_captcha", "invalid_credentials", "max_attempts", "network_error". func errorCheck(cookies types.Cookies) string { client := helpers.GetHTTPClient() req, err := http.NewRequest("GET", "https://vtop.vit.ac.in/vtop/login/error", nil) - if err != nil && debug.Debug { - helpers.Println(err) + if err != nil { + if debug.Debug { + helpers.Println(err) + } + return "network_error" } helpers.SetVtopHeaders(req) req.Header.Set("Cache-Control", "max-age=0") req.Header.Set("Referer", "https://vtop.vit.ac.in/vtop/login") req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s; SERVERID=%s", cookies.JSESSIONID, cookies.SERVERID)) resp, err := client.Do(req) - if err != nil && debug.Debug { - helpers.Println(err) + if err != nil { + if debug.Debug { + helpers.Println(err) + } + return "network_error" } defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + if debug.Debug { + helpers.Printf("VTOP login error check failed with status %s\n", resp.Status) + } + return "network_error" + } bodyText, err := io.ReadAll(resp.Body) - if err != nil && debug.Debug { - helpers.Println(err) + if err != nil { + if debug.Debug { + helpers.Println(err) + } + return "network_error" } if strings.Contains(string(bodyText), "Invalid Captcha") { @@ -104,7 +132,13 @@ func Login(regNo string, password string) types.Cookies { } for attempt := 0; attempt < maxCaptchaRetries; attempt++ { - vtopTokens, captcha := getLoginPage() + vtopTokens, captcha, err := getLoginPage() + if err != nil { + if debug.Debug { + helpers.Println(err) + } + return types.Cookies{} + } loginCreds, errType := performLogin(userInfo, vtopTokens, captcha) if errType == "" { @@ -129,8 +163,11 @@ func Login(regNo string, password string) types.Cookies { func HomePage(vtopTokens types.Cookies) (types.Cookies, string) { client := helpers.GetHTTPClient() req, err := http.NewRequest("GET", "https://vtop.vit.ac.in/vtop/init/page", nil) - if err != nil && debug.Debug { - helpers.Println(err) + if err != nil { + if debug.Debug { + helpers.Println(err) + } + return vtopTokens, "" } helpers.SetVtopHeaders(req) req.Header.Set("Cache-Control", "max-age=0") @@ -138,12 +175,27 @@ func HomePage(vtopTokens types.Cookies) (types.Cookies, string) { req.Header.Set("Referer", "https://vtop.vit.ac.in/vtop/login") req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s; SERVERID=%s", vtopTokens.JSESSIONID, vtopTokens.SERVERID)) resp, err := client.Do(req) - if err != nil && debug.Debug { - helpers.Println(err) + if err != nil { + if debug.Debug { + helpers.Println(err) + } + return vtopTokens, "" } defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + if debug.Debug { + helpers.Printf("VTOP home page request failed with status %s\n", resp.Status) + } + return vtopTokens, "" + } - bodyText := helpers.ExtractBodyText(resp) + bodyText, err := io.ReadAll(resp.Body) + if err != nil { + if debug.Debug { + helpers.Println(err) + } + return vtopTokens, "" + } if strings.Contains(string(bodyText), "Session Timed Out") { if debug.Debug { @@ -152,43 +204,12 @@ func HomePage(vtopTokens types.Cookies) (types.Cookies, string) { return vtopTokens, "" } - vtopTokens.CSRF = helpers.ExtractCSRF2(bodyText) + vtopTokens.CSRF = helpers.ExtractCSRF2(string(bodyText)) - RegNo, err := helpers.ExtractRegNo(bodyText) + RegNo, err := helpers.ExtractRegNo(string(bodyText)) if err != nil && debug.Debug { - // Handle the error helpers.Println("Error:", err) - // You might want to return or log the error, or take other appropriate actions - } - - if debug.Debug { - helpers.Println("(Helper - ExtractRegNo):", RegNo) } return vtopTokens, RegNo } - -func AutoRelogin(regNo string) (types.Cookies, bool) { - username := os.Getenv("VTOP_USERNAME") - password := os.Getenv("PASSWORD") - if username == "" || password == "" { - if debug.Debug { - helpers.Println("No stored credentials found for auto-relogin.") - } - return types.Cookies{}, false - } - if debug.Debug { - helpers.Println("Attempting auto-relogin with stored credentials...") - } - newCookies := Login(username, password) - if helpers.ValidateCookies(newCookies) { - if debug.Debug { - helpers.Println("Auto-relogin successful.") - } - return newCookies, true - } - if debug.Debug { - helpers.Println("Auto-relogin failed.") - } - return types.Cookies{}, false -} diff --git a/login/pre-login.go b/login/pre-login.go index 0eb05d46..a00be866 100644 --- a/login/pre-login.go +++ b/login/pre-login.go @@ -1,77 +1,92 @@ package login import ( - "cli-top/debug" "cli-top/helpers" types "cli-top/types" "fmt" "io" "net/http" + "net/url" "strings" ) -func getSessionServer() types.Cookies { +func getSessionServer() (types.Cookies, error) { client := helpers.GetHTTPClient() req, err := http.NewRequest("GET", "https://vtop.vit.ac.in/", nil) - if err != nil && debug.Debug { - helpers.Println(err) + if err != nil { + return types.Cookies{}, err } helpers.SetVtopHeaders(req) req.Header.Set("Sec-Fetch-Site", "none") resp, err := client.Do(req) - if err != nil && debug.Debug { - helpers.Println(err) + if err != nil { + return types.Cookies{}, err } defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return types.Cookies{}, fmt.Errorf("VTOP session request failed with status %s", resp.Status) + } + bodyText, err := io.ReadAll(resp.Body) + if err != nil { + return types.Cookies{}, err + } vtopCookies := helpers.ExtractCookies(resp) - vtopCookies.CSRF = helpers.ExtractCSRF(helpers.ExtractBodyText(resp)) + vtopCookies.CSRF = helpers.ExtractCSRF(string(bodyText)) - return vtopCookies + return vtopCookies, nil } -func getLoginPage() (types.Cookies, string) { +func getLoginPage() (types.Cookies, string, error) { + const maxPreloginAttempts = 3 + client := helpers.GetHTTPClient() + for attempt := 0; attempt < maxPreloginAttempts; attempt++ { + cookies, err := getSessionServer() + if err != nil { + return types.Cookies{}, "", err + } - cookies := getSessionServer() + data := strings.NewReader(url.Values{ + "_csrf": {cookies.CSRF}, + "flag": {"VTOP"}, + }.Encode()) + req, err := http.NewRequest("POST", "https://vtop.vit.ac.in/vtop/prelogin/setup", data) + if err != nil { + return types.Cookies{}, "", err + } + helpers.SetVtopHeaders(req) + req.Header.Set("Cache-Control", "max-age=0") + req.Header.Set("Origin", "https://vtop.vit.ac.in") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Sec-Fetch-Site", "same-origin") + req.Header.Set("Referer", "https://vtop.vit.ac.in/vtop/open/page") + req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s; SERVERID=%s", cookies.JSESSIONID, cookies.SERVERID)) + resp, err := client.Do(req) + if err != nil { + return types.Cookies{}, "", err + } - client := helpers.GetHTTPClient() - var data = strings.NewReader(fmt.Sprintf(`_csrf=%s&flag=VTOP`, cookies.CSRF)) - req, err := http.NewRequest("POST", "https://vtop.vit.ac.in/vtop/prelogin/setup", data) - if err != nil && debug.Debug { - helpers.Println(err) - } - helpers.SetVtopHeaders(req) - req.Header.Set("Cache-Control", "max-age=0") - req.Header.Set("Origin", "https://vtop.vit.ac.in") - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Sec-Fetch-Site", "same-origin") - req.Header.Set("Referer", "https://vtop.vit.ac.in/vtop/open/page") - req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s; SERVERID=%s", cookies.JSESSIONID, cookies.SERVERID)) - resp, err := client.Do(req) - if err != nil && debug.Debug { - helpers.Println(err) - } - defer resp.Body.Close() + bodyText, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return types.Cookies{}, "", readErr + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return types.Cookies{}, "", fmt.Errorf("VTOP prelogin request failed with status %s", resp.Status) + } - bodyText, err := io.ReadAll(resp.Body) - if err != nil && debug.Debug { - helpers.Println(err) - } - // helpers.Printf("%s\n", bodyText) + captchaImage := helpers.ExtractImage(string(bodyText)) + if captchaImage == "" || captchaImage == "nocaptcha" { + continue + } - stringBody := string(bodyText) - captchaImage := helpers.ExtractImage(stringBody) - if captchaImage == "nocaptcha" { - // Vtop does not always send a captcha image, so try again - return getLoginPage() - } - // helpers.Println("getLoginPage() - Captcha:", captchaImage) + captcha := helpers.SolveCaptcha(captchaImage) + if captcha == "" || captcha == "disabled" { + return types.Cookies{}, "", fmt.Errorf("captcha solving is unavailable") + } - captcha := helpers.SolveCaptcha(captchaImage) - if strings.Contains(captcha, "disabled") { - helpers.Println("Captcha auto-solver has been disabled. \nPlease manually solve the captcha and answer here:") - fmt.Scanln(&captcha) + return cookies, captcha, nil } - return cookies, captcha + return types.Cookies{}, "", fmt.Errorf("VTOP did not provide a captcha after %d attempts", maxPreloginAttempts) } diff --git a/package.json b/package.json index 3bf7f523..19044236 100644 --- a/package.json +++ b/package.json @@ -22,5 +22,5 @@ "type": "git", "url": "https://github.com/ACM-VIT/cli-top" }, - "license": "UNLICENSED" + "license": "GPL-3.0-only" } diff --git a/tests/cli_e2e_test.go b/tests/cli_e2e_test.go new file mode 100644 index 00000000..ec66ac6e --- /dev/null +++ b/tests/cli_e2e_test.go @@ -0,0 +1,408 @@ +package tests + +import ( + "bytes" + "cli-top/debug" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/lpernett/godotenv" +) + +var cliE2EVisibleCommands = []string{ + "attendance", + "calendar", + "cgpa", + "completion", + "course-allocation", + "course-page", + "course-page-archive", + "da", + "dayafter", + "events", + "exams", + "facility", + "grades", + "holiday", + "hostel", + "leave", + "library-dues", + "login", + "logout", + "marks", + "msg", + "nightslip", + "profile", + "receipts", + "syllabus", + "timetable", + "today", + "tomorrow", +} + +func TestCLIEndToEndSmoke(t *testing.T) { + repoRoot := findRepoRoot(t) + binPath := buildLocalBinary(t, repoRoot) + + var latestJSONRequests atomic.Int32 + latestJSONServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + latestJSONRequests.Add(1) + if r.Method != http.MethodGet { + t.Errorf("latest.json request method = %s, want GET", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"version":%q,"killSwitch":0}`, debug.Version) + })) + t.Cleanup(latestJSONServer.Close) + + var unexpectedNetworkRequests atomic.Int32 + networkTrap := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + unexpectedNetworkRequests.Add(1) + http.Error(w, "external network disabled by CLI E2E test", http.StatusBadGateway) + })) + t.Cleanup(networkTrap.Close) + + t.Run("visible command help", func(t *testing.T) { + cwd, env, _ := newCLIE2ESandbox(t, latestJSONServer.URL, networkTrap.URL) + requestsBefore := latestJSONRequests.Load() + + stdout, stderr := runCLIE2ECommand(t, binPath, env, cwd, "--help") + if stderr != "" { + t.Fatalf("cli-top --help wrote to stderr:\n%s", stderr) + } + if got := parseCommands(stdout); !reflect.DeepEqual(got, cliE2EVisibleCommands) { + t.Fatalf("visible command set mismatch\n got: %q\nwant: %q", got, cliE2EVisibleCommands) + } + + commandsAndAliases := append([]string{}, cliE2EVisibleCommands...) + commandsAndAliases = append(commandsAndAliases, "day-after", "class-messages") + for _, command := range commandsAndAliases { + command := command + t.Run(command, func(t *testing.T) { + stdout, stderr := runCLIE2ECommand(t, binPath, env, cwd, command, "--help") + if stderr != "" { + t.Fatalf("cli-top %s --help wrote to stderr:\n%s", command, stderr) + } + if !strings.Contains(stdout, "Usage:") { + t.Fatalf("cli-top %s --help did not render usage:\n%s", command, stdout) + } + }) + } + + if got := latestJSONRequests.Load(); got != requestsBefore { + t.Fatalf("help commands made %d latest.json requests, want 0", got-requestsBefore) + } + }) + + t.Run("version", func(t *testing.T) { + cwd, env, _ := newCLIE2ESandbox(t, latestJSONServer.URL, networkTrap.URL) + requestsBefore := latestJSONRequests.Load() + + stdout, stderr := runCLIE2ECommand(t, binPath, env, cwd, "--version") + if stderr != "" { + t.Fatalf("cli-top --version wrote to stderr:\n%s", stderr) + } + want := fmt.Sprintf("Version: %s", debug.Version) + if got := strings.TrimSpace(stdout); got != want { + t.Fatalf("cli-top --version = %q, want %q", got, want) + } + if got := latestJSONRequests.Load(); got != requestsBefore { + t.Fatalf("--version made %d latest.json requests, want 0", got-requestsBefore) + } + }) + + t.Run("completion generates bash script", func(t *testing.T) { + cwd, env, _ := newCLIE2ESandbox(t, latestJSONServer.URL, networkTrap.URL) + requestsBefore := latestJSONRequests.Load() + + stdout, stderr := runCLIE2ECommand(t, binPath, env, cwd, "completion", "bash") + if stderr != "" { + t.Fatalf("cli-top completion bash wrote to stderr:\n%s", stderr) + } + for _, want := range []string{"__start_cli-top", "complete -o default -F __start_cli-top cli-top"} { + if !strings.Contains(stdout, want) { + t.Fatalf("completion script is missing %q:\n%s", want, stdout) + } + } + if got := latestJSONRequests.Load(); got != requestsBefore { + t.Fatalf("completion made %d latest.json requests, want 0", got-requestsBefore) + } + }) + + t.Run("root welcome", func(t *testing.T) { + cwd, env, configPath := newCLIE2ESandbox(t, latestJSONServer.URL, networkTrap.URL) + writeCLIE2EConfig(t, configPath, "UUID=e2e-welcome-uuid\n", 0o600) + requestsBefore := latestJSONRequests.Load() + + stdout, stderr := runCLIE2ECommand(t, binPath, env, cwd) + if stderr != "" { + t.Fatalf("cli-top wrote to stderr:\n%s", stderr) + } + if !strings.Contains(stdout, "Welcome to CLI-TOP!") || !strings.Contains(stdout, `Use "cli-top help"`) { + t.Fatalf("root command did not render the welcome screen:\n%s", stdout) + } + if got := latestJSONRequests.Load(); got != requestsBefore+1 { + t.Fatalf("root command made %d latest.json requests, want 1", got-requestsBefore) + } + }) + + t.Run("update confirms current version", func(t *testing.T) { + cwd, env, configPath := newCLIE2ESandbox(t, latestJSONServer.URL, networkTrap.URL) + writeCLIE2EConfig(t, configPath, "UUID=e2e-update-uuid\n", 0o600) + requestsBefore := latestJSONRequests.Load() + + stdout, stderr := runCLIE2ECommand(t, binPath, env, cwd, "--update") + if stderr != "" { + t.Fatalf("cli-top --update wrote to stderr:\n%s", stderr) + } + if !strings.Contains(stdout, "You are using the latest stable version of cli-top.") { + t.Fatalf("update command did not report the current version:\n%s", stdout) + } + if got := latestJSONRequests.Load(); got != requestsBefore+2 { + t.Fatalf("--update made %d latest.json requests, want 2", got-requestsBefore) + } + }) + + t.Run("login encrypts credentials", func(t *testing.T) { + cwd, env, configPath := newCLIE2ESandbox(t, latestJSONServer.URL, networkTrap.URL) + const ( + userUUID = "e2e-login-uuid" + username = "22bce9999" + password = "e2e-plaintext-password" + ) + writeCLIE2EConfig(t, configPath, "UUID="+userUUID+"\n", 0o644) + requestsBefore := latestJSONRequests.Load() + + stdout, stderr := runCLIE2ECommand(t, binPath, env, cwd, + "login", "--username", username, "--password", password, + ) + if stderr != "" { + t.Fatalf("cli-top login wrote to stderr:\n%s", stderr) + } + if !strings.Contains(stdout, "Username and encrypted password stored") { + t.Fatalf("cli-top login did not report success:\n%s", stdout) + } + if got := latestJSONRequests.Load(); got != requestsBefore+1 { + t.Fatalf("login made %d latest.json requests, want 1", got-requestsBefore) + } + + raw, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read login config: %v", err) + } + if bytes.Contains(raw, []byte(password)) { + t.Fatalf("login config contains plaintext password: %s", raw) + } + config, err := godotenv.Read(configPath) + if err != nil { + t.Fatalf("parse login config: %v", err) + } + if got := config["UUID"]; got != userUUID { + t.Fatalf("UUID = %q, want %q", got, userUUID) + } + if got := config["VTOP_USERNAME"]; got != strings.ToUpper(username) { + t.Fatalf("VTOP_USERNAME = %q, want %q", got, strings.ToUpper(username)) + } + encryptedPassword := config["PASSWORD"] + if encryptedPassword == "" || encryptedPassword == password { + t.Fatalf("PASSWORD was not encrypted: %q", encryptedPassword) + } + decodedPassword, err := base64.URLEncoding.DecodeString(encryptedPassword) + if err != nil { + t.Fatalf("PASSWORD is not URL-safe base64 ciphertext: %v", err) + } + if len(decodedPassword) <= len(password) { + t.Fatalf("ciphertext length = %d, want more than plaintext length %d", len(decodedPassword), len(password)) + } + if got := len(config["KEY"]); got != 32 { + t.Fatalf("KEY length = %d, want 32", got) + } + + if runtime.GOOS != "windows" && runtime.GOOS != "plan9" { + info, err := os.Stat(configPath) + if err != nil { + t.Fatalf("stat login config: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("login config mode = %04o, want 0600", got) + } + } + }) + + t.Run("logout preserves only UUID", func(t *testing.T) { + cwd, env, configPath := newCLIE2ESandbox(t, latestJSONServer.URL, networkTrap.URL) + const userUUID = "e2e-logout-uuid" + writeCLIE2EConfig(t, configPath, strings.Join([]string{ + "UUID=" + userUUID, + `VTOP_USERNAME="22BCE9999"`, + `PASSWORD="encrypted-password"`, + `KEY="01234567890123456789012345678901"`, + `CSRF="csrf"`, + `JSESSIONID="session"`, + `SERVERID="server"`, + `REGNO="22BCE9999"`, + }, "\n")+"\n", 0o600) + requestsBefore := latestJSONRequests.Load() + + stdout, stderr := runCLIE2ECommand(t, binPath, env, cwd, "logout") + if stderr != "" { + t.Fatalf("cli-top logout wrote to stderr:\n%s", stderr) + } + if !strings.Contains(stdout, "Logged out successfully.") { + t.Fatalf("cli-top logout did not report success:\n%s", stdout) + } + if got := latestJSONRequests.Load(); got != requestsBefore+1 { + t.Fatalf("logout made %d latest.json requests, want 1", got-requestsBefore) + } + + config, err := godotenv.Read(configPath) + if err != nil { + t.Fatalf("parse logout config: %v", err) + } + want := map[string]string{"UUID": userUUID} + if !reflect.DeepEqual(config, want) { + t.Fatalf("logout config = %#v, want %#v", config, want) + } + }) + + t.Run("proxy rejects empty credentials as JSON", func(t *testing.T) { + cwd, env, configPath := newCLIE2ESandbox(t, latestJSONServer.URL, networkTrap.URL) + writeCLIE2EConfig(t, configPath, "UUID=e2e-proxy-uuid\n", 0o600) + requestsBefore := latestJSONRequests.Load() + + stdout, stderr := runCLIE2ECommand(t, binPath, env, cwd, "proxy", "", "", "profile") + if stderr != "" { + t.Fatalf("cli-top proxy wrote to stderr:\n%s", stderr) + } + if got := latestJSONRequests.Load(); got != requestsBefore+1 { + t.Fatalf("proxy made %d latest.json requests, want 1", got-requestsBefore) + } + + var response struct { + Success bool `json:"success"` + Command string `json:"command"` + Version string `json:"version"` + Error *struct { + Kind string `json:"kind"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("proxy output is not valid JSON: %v\n%s", err, stdout) + } + if response.Success { + t.Fatalf("proxy success = true, want false: %s", stdout) + } + if response.Command != "profile" { + t.Fatalf("proxy command = %q, want profile", response.Command) + } + if response.Version != debug.Version { + t.Fatalf("proxy version = %q, want %q", response.Version, debug.Version) + } + if response.Error == nil || response.Error.Kind != "auth_failed" || response.Error.Message != "missing VTOP credentials" { + t.Fatalf("proxy error = %#v, want auth_failed for missing credentials", response.Error) + } + }) + + if got := unexpectedNetworkRequests.Load(); got != 0 { + t.Fatalf("CLI attempted %d external network requests", got) + } +} + +func newCLIE2ESandbox(t *testing.T, latestJSONURL, networkTrapURL string) (cwd string, env []string, configPath string) { + t.Helper() + + root := t.TempDir() + home := filepath.Join(root, "home") + cwd = filepath.Join(root, "work") + for _, dir := range []string{home, cwd, filepath.Join(root, "config"), filepath.Join(root, "appdata")} { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("create CLI E2E sandbox directory: %v", err) + } + } + + removed := map[string]bool{ + "HOME": true, "USERPROFILE": true, "XDG_CONFIG_HOME": true, + "APPDATA": true, "LOCALAPPDATA": true, + "CLI_TOP_LATEST_JSON_URL": true, "CLI_TOP_PROXY_MODE": true, + "NO_COLOR": true, "TERM": true, + "HTTP_PROXY": true, "HTTPS_PROXY": true, "ALL_PROXY": true, "NO_PROXY": true, + "http_proxy": true, "https_proxy": true, "all_proxy": true, "no_proxy": true, + "UUID": true, "UNREGISTERED_UUID": true, + "VTOP_USERNAME": true, "PASSWORD": true, "KEY": true, + "CSRF": true, "JSESSIONID": true, "SERVERID": true, "REGNO": true, + } + for _, value := range os.Environ() { + key, _, _ := strings.Cut(value, "=") + if !removed[key] { + env = append(env, value) + } + } + env = append(env, + "HOME="+home, + "USERPROFILE="+home, + "XDG_CONFIG_HOME="+filepath.Join(root, "config"), + "APPDATA="+filepath.Join(root, "appdata"), + "LOCALAPPDATA="+filepath.Join(root, "appdata"), + "CLI_TOP_LATEST_JSON_URL="+latestJSONURL, + "NO_COLOR=1", + "TERM=dumb", + "HTTP_PROXY="+networkTrapURL, + "HTTPS_PROXY="+networkTrapURL, + "ALL_PROXY="+networkTrapURL, + "NO_PROXY=127.0.0.1,localhost", + "http_proxy="+networkTrapURL, + "https_proxy="+networkTrapURL, + "all_proxy="+networkTrapURL, + "no_proxy=127.0.0.1,localhost", + ) + + return cwd, env, filepath.Join(cwd, "cli-top-config.env") +} + +func writeCLIE2EConfig(t *testing.T, path, content string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatalf("write CLI E2E config: %v", err) + } + if runtime.GOOS != "windows" && runtime.GOOS != "plan9" { + if err := os.Chmod(path, mode); err != nil { + t.Fatalf("chmod CLI E2E config: %v", err) + } + } +} + +func runCLIE2ECommand(t *testing.T, binPath string, env []string, cwd string, args ...string) (stdout, stderr string) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + command := exec.CommandContext(ctx, binPath, args...) + command.Dir = cwd + command.Env = env + var stdoutBuffer, stderrBuffer bytes.Buffer + command.Stdout = &stdoutBuffer + command.Stderr = &stderrBuffer + err := command.Run() + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("cli-top %q timed out\nstdout:\n%s\nstderr:\n%s", args, stdoutBuffer.String(), stderrBuffer.String()) + } + if err != nil { + t.Fatalf("cli-top %q failed: %v\nstdout:\n%s\nstderr:\n%s", args, err, stdoutBuffer.String(), stderrBuffer.String()) + } + return stdoutBuffer.String(), stderrBuffer.String() +} diff --git a/tests/facility_killswitch_test.go b/tests/facility_killswitch_test.go new file mode 100644 index 00000000..4cba603a --- /dev/null +++ b/tests/facility_killswitch_test.go @@ -0,0 +1,37 @@ +package tests + +import ( + "net/http/httptest" + "testing" + + "cli-top/features" + "cli-top/helpers" +) + +func TestFacilityRegistrationFailsClosedWhenKillSwitchIsUnavailable(t *testing.T) { + originalLatestJSONURL := helpers.GetLatestJSONURL() + t.Cleanup(func() { helpers.SetLatestJSONURL(originalLatestJSONURL) }) + + server := httptest.NewServer(nil) + serverURL := server.URL + server.Close() + + t.Setenv(latestJSONURLEnv, serverURL) + helpers.SetLatestJSONURL(originalLatestJSONURL) + transport := installSharedWorkflowTransport(t, facilityWorkflowResponse) + + if err := features.RegisterPhyFacility(interactiveFeatureRegNo, interactiveFeatureCookies, "Basketball", true); err != nil { + t.Fatalf("RegisterPhyFacility returned error: %v", err) + } + + assertWorkflowTransportClean(t, transport) + if transport.submitted { + t.Fatal("facility registration was submitted while kill-switch metadata was unavailable") + } + requireWorkflowRequest(t, transport, "/vtop/phyedu/facilityAvailable", 2) + for _, request := range transport.requests { + if request.path == "/vtop/phyedu/PhyFacilityProcessRegistration" { + t.Fatalf("unexpected facility registration request: %#v", request) + } + } +} diff --git a/tests/feature_academic_e2e_test.go b/tests/feature_academic_e2e_test.go new file mode 100644 index 00000000..99e76c2d --- /dev/null +++ b/tests/feature_academic_e2e_test.go @@ -0,0 +1,274 @@ +package tests + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + + "cli-top/features" + "cli-top/helpers" + "cli-top/types" +) + +const ( + academicSemesterPath = "/vtop/academics/common/StudentAttendance" + academicExamPath = "/vtop/examinations/doSearchExamScheduleForStudent" + selectedSemesterID = "SEM-SELECTED" +) + +var academicFeatureCookies = types.Cookies{ + SERVERID: "fake-server", + CSRF: "fake-csrf", + JSESSIONID: "fake-session", +} + +type academicFeatureRoundTripper struct { + featurePath string + featureHTML string + semesterRequests int + featureRequests int + selectedSemester string + authorizedID string + formParseError error + uploadRequests int + uploadedICS string + uploadParseError error +} + +func (rt *academicFeatureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPost { + return nil, fmt.Errorf("unexpected %s request for %s", req.Method, req.URL.Path) + } + + var body string + switch req.URL.Path { + case academicSemesterPath: + rt.semesterRequests++ + body = ` + ` + case rt.featurePath: + rt.featureRequests++ + if strings.HasPrefix(req.Header.Get("Content-Type"), "multipart/form-data") { + rt.formParseError = req.ParseMultipartForm(1 << 20) + } else { + rt.formParseError = req.ParseForm() + } + if rt.formParseError == nil { + rt.selectedSemester = req.Form.Get("semesterSubId") + rt.authorizedID = req.Form.Get("authorizedID") + } + body = rt.featureHTML + case "/upload": + rt.uploadRequests++ + rt.uploadParseError = req.ParseMultipartForm(1 << 20) + if rt.uploadParseError == nil { + file, _, err := req.FormFile("file") + if err != nil { + rt.uploadParseError = err + } else { + uploaded, readErr := io.ReadAll(file) + _ = file.Close() + if readErr != nil { + rt.uploadParseError = readErr + } else { + rt.uploadedICS = string(uploaded) + } + } + } + body = `{"url":"https://calendar.test/exams.ics"}` + default: + return nil, fmt.Errorf("unexpected request path %q", req.URL.Path) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil +} + +func TestAcademicFeaturesEndToEnd(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("CLI_TOP_PROXY_MODE", "1") + t.Setenv("SEMESTER_CACHE", "") + + t.Run("marks", func(t *testing.T) { + tables := exerciseAcademicFeature(t, "E2E-MARKS-0001", "/vtop/examinations/doStudentMarkView", ` + + + + + +
1-CSE1001Data StructuresTheory Only-Dr AdaA1
+ + + + +
1CAT 15015Present4513.50
`, features.GetMarks) + + assertAcademicTables(t, tables, []helpers.TableSnapshot{{ + Headers: []string{"TITLE", "MAX MARKS", "WEIGHTAGE %", "STATUS", "SCORED MARK", "WEIGHTAGE MARK"}, + Rows: [][]string{{"CAT 1", "50", "15", "Present", "45", "13.50"}}, + }}) + }) + + t.Run("grades", func(t *testing.T) { + tables := exerciseAcademicFeature(t, "E2E-GRADES-0002", "/vtop/examinations/examGradeView/doStudentGradeView", ` + + + + + + + +
1CSE2001AlgorithmsTheory Only---4.0RG87A
`, features.GetGrades) + + assertAcademicTables(t, tables, []helpers.TableSnapshot{{ + Headers: []string{"INDEX", "COURSE CODE", "COURSE TITLE", "COURSE TYPE", "CREDITS", "TOTAL", "GRADING", "GRADE"}, + Rows: [][]string{{"1", "CSE2001", "Algorithms", "Theory Only", "4.0", "87", "Relative", "A"}}, + HasIndex: true, + }}) + }) + + t.Run("attendance", func(t *testing.T) { + tables := exerciseAcademicFeature(t, "E2E-ATTENDANCE-0003", "/vtop/processViewStudentAttendance", ` + + + + + + + + +
1-CSE2001 - Algorithms - Theory Only-DR ADA LOVELACE - 100181080%
`, features.GetAttendance) + + assertAcademicTables(t, tables, []helpers.TableSnapshot{{ + Headers: []string{"INDEX", "SUBJECT", "TYPE", "FACULTY NAME", "CLASSES ATTENDED", "PERCENTAGE", "75% ALERT"}, + Rows: [][]string{{"1", "Algorithms", "Theory Only", "Dr Ada Lovelace", "8/10", "80%", "Can miss 0 class(es)"}}, + HasIndex: true, + }}) + }) + + t.Run("exam schedule", func(t *testing.T) { + tables := exerciseAcademicFeature(t, "E2E-EXAMS-0004", academicExamPath, ` + + + + + + + + +
CAT1
1CSE3001Compiler Design--D131-Dec-2099-8:45 AM9:00 AM - 12:00 PMSJT-101A42
`, features.GetExamSchedule) + + if len(tables) != 1 { + t.Fatalf("captured %d exam tables, want 1: %#v", len(tables), tables) + } + wantHeaders := []string{"INDEX", "CODE", "COURSE TITLE", "SLOT", "EXAM DATE", "EXAM TIME", "VENUE", "SEAT", "SEAT NO.", "DAYS LEFT"} + if !reflect.DeepEqual(tables[0].Headers, wantHeaders) { + t.Fatalf("exam headers = %#v, want %#v", tables[0].Headers, wantHeaders) + } + if !tables[0].HasIndex || len(tables[0].Rows) != 1 || len(tables[0].Rows[0]) != len(wantHeaders) { + t.Fatalf("unexpected exam table snapshot: %#v", tables[0]) + } + wantRowPrefix := []string{"1", "CSE3001", "Compiler Design", "D1", "31-Dec-2099", "9:00 AM - 12:00 PM (Report by: 8:45 AM)", "SJT-101", "A", "42"} + if !reflect.DeepEqual(tables[0].Rows[0][:len(wantRowPrefix)], wantRowPrefix) { + t.Fatalf("exam row = %#v, want prefix %#v", tables[0].Rows[0], wantRowPrefix) + } + if daysLeft, err := strconv.Atoi(tables[0].Rows[0][len(wantRowPrefix)]); err != nil || daysLeft <= 0 { + t.Fatalf("exam days left = %q, want a positive integer", tables[0].Rows[0][len(wantRowPrefix)]) + } + + icsPath := filepath.Join(home, "Downloads", "CLI-TOP Downloads", "Other Downloads", "ICS File", "Exam_Schedule.ics") + ics, err := os.ReadFile(icsPath) + if err != nil { + t.Fatalf("read generated exam ICS file: %v", err) + } + for _, want := range []string{"BEGIN:VCALENDAR", "DTSTART;TZID=Asia/Kolkata:20991231T090000", "SUMMARY:CAT1: D1 - Compiler Design", "END:VCALENDAR"} { + if !strings.Contains(string(ics), want) { + t.Errorf("generated ICS is missing %q:\n%s", want, ics) + } + } + }) +} + +func exerciseAcademicFeature( + t *testing.T, + regNo string, + featurePath string, + featureHTML string, + run func(string, types.Cookies, int), +) []helpers.TableSnapshot { + t.Helper() + + helpers.InvalidateSemesterCache(regNo) + t.Cleanup(func() { helpers.InvalidateSemesterCache(regNo) }) + + transport := &academicFeatureRoundTripper{ + featurePath: featurePath, + featureHTML: featureHTML, + } + client := helpers.GetHTTPClient() + previousTransport := client.Transport + client.Transport = transport + t.Cleanup(func() { client.Transport = previousTransport }) + + var tables []helpers.TableSnapshot + restoreTableCapture := helpers.RegisterTableCaptureHook(func(snapshot helpers.TableSnapshot) { + tables = append(tables, snapshot) + }) + t.Cleanup(restoreTableCapture) + + run(regNo, academicFeatureCookies, 2) + + if transport.formParseError != nil { + t.Fatalf("parse feature form: %v", transport.formParseError) + } + if transport.uploadParseError != nil { + t.Fatalf("parse calendar upload: %v", transport.uploadParseError) + } + if featurePath == academicExamPath { + if transport.uploadRequests != 1 { + t.Fatalf("calendar upload count = %d, want 1", transport.uploadRequests) + } + if !strings.Contains(transport.uploadedICS, "SUMMARY:CAT1: D1 - Compiler Design") { + t.Fatalf("uploaded calendar did not contain the exam event:\n%s", transport.uploadedICS) + } + } + if transport.semesterRequests != 1 { + t.Fatalf("semester request count = %d, want 1", transport.semesterRequests) + } + if transport.featureRequests != 1 { + t.Fatalf("feature request count = %d, want 1", transport.featureRequests) + } + if transport.selectedSemester != selectedSemesterID { + t.Fatalf("posted semester = %q, want %q", transport.selectedSemester, selectedSemesterID) + } + if transport.authorizedID != regNo { + t.Fatalf("posted authorizedID = %q, want %q", transport.authorizedID, regNo) + } + + return tables +} + +func assertAcademicTables(t *testing.T, got, want []helpers.TableSnapshot) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("captured tables = %#v, want %#v", got, want) + } +} diff --git a/tests/feature_download_e2e_test.go b/tests/feature_download_e2e_test.go new file mode 100644 index 00000000..a2b55bb7 --- /dev/null +++ b/tests/feature_download_e2e_test.go @@ -0,0 +1,539 @@ +package tests + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "testing" + + "cli-top/features" + "cli-top/helpers" + "cli-top/types" +) + +const downloadE2EHost = "vtop.vit.ac.in" + +var downloadE2ECookies = types.Cookies{ + CSRF: "fake-download-csrf", + JSESSIONID: "fake-download-session", + SERVERID: "fake-download-server", +} + +type downloadE2ERoute struct { + body []byte + headers http.Header + wantForm map[string]string + nonEmptyForm []string + expectedCalls int +} + +type downloadE2ERoundTripper struct { + cookies types.Cookies + routes map[string]downloadE2ERoute + + mu sync.Mutex + callCounts map[string]int + violations []string +} + +func newDownloadE2ERoundTripper(routes map[string]downloadE2ERoute) *downloadE2ERoundTripper { + return &downloadE2ERoundTripper{ + cookies: downloadE2ECookies, + routes: routes, + callCounts: make(map[string]int), + } +} + +func (rt *downloadE2ERoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + path := req.URL.Path + route, exists := rt.routes[path] + + body, readErr := io.ReadAll(req.Body) + form, formErr := url.ParseQuery(string(body)) + issues := rt.validateRequest(req, route, exists, form, readErr, formErr) + + rt.mu.Lock() + rt.callCounts[path]++ + rt.violations = append(rt.violations, issues...) + rt.mu.Unlock() + + if !exists { + return nil, fmt.Errorf("download e2e transport rejected unexpected path %q", path) + } + + headers := make(http.Header) + for key, values := range route.headers { + headers[key] = append([]string(nil), values...) + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: headers, + Body: io.NopCloser(bytes.NewReader(route.body)), + Request: req, + }, nil +} + +func (rt *downloadE2ERoundTripper) validateRequest( + req *http.Request, + route downloadE2ERoute, + routeExists bool, + form url.Values, + readErr error, + formErr error, +) []string { + var issues []string + prefix := req.URL.Path + ": " + + if req.Method != http.MethodPost { + issues = append(issues, prefix+fmt.Sprintf("method = %q, want POST", req.Method)) + } + if req.URL.Scheme != "https" || req.URL.Host != downloadE2EHost { + issues = append(issues, prefix+fmt.Sprintf("URL = %q, want https://%s/...", req.URL.String(), downloadE2EHost)) + } + if req.URL.RawQuery != "" { + issues = append(issues, prefix+fmt.Sprintf("unexpected query string %q", req.URL.RawQuery)) + } + if got := req.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" { + issues = append(issues, prefix+fmt.Sprintf("Content-Type = %q", got)) + } + if readErr != nil { + issues = append(issues, prefix+fmt.Sprintf("read body: %v", readErr)) + } + if formErr != nil { + issues = append(issues, prefix+fmt.Sprintf("parse form: %v", formErr)) + } + + cookies := make(map[string]string) + for _, cookie := range req.Cookies() { + cookies[cookie.Name] = cookie.Value + } + wantCookies := map[string]string{ + "JSESSIONID": rt.cookies.JSESSIONID, + "SERVERID": rt.cookies.SERVERID, + } + if len(cookies) != len(wantCookies) { + issues = append(issues, prefix+fmt.Sprintf("cookies = %#v, want exactly the fake session cookies", cookies)) + } + for name, want := range wantCookies { + if got := cookies[name]; got != want { + issues = append(issues, prefix+fmt.Sprintf("cookie %s = %q, want %q", name, got, want)) + } + } + + if !routeExists { + issues = append(issues, prefix+"path is not registered") + return issues + } + + wantKeyCount := len(route.wantForm) + len(route.nonEmptyForm) + if len(form) != wantKeyCount { + issues = append(issues, prefix+fmt.Sprintf("form keys = %#v, want %d exact keys", sortedDownloadE2EKeys(form), wantKeyCount)) + } + for key, want := range route.wantForm { + values, ok := form[key] + if !ok || len(values) != 1 || values[0] != want { + issues = append(issues, prefix+fmt.Sprintf("form[%s] = %#v, want [%q]", key, values, want)) + } + } + for _, key := range route.nonEmptyForm { + values, ok := form[key] + if !ok || len(values) != 1 || strings.TrimSpace(values[0]) == "" { + issues = append(issues, prefix+fmt.Sprintf("form[%s] = %#v, want one non-empty value", key, values)) + } + } + + return issues +} + +func (rt *downloadE2ERoundTripper) assertDone(t *testing.T) { + t.Helper() + + rt.mu.Lock() + counts := make(map[string]int, len(rt.callCounts)) + for path, count := range rt.callCounts { + counts[path] = count + } + violations := append([]string(nil), rt.violations...) + rt.mu.Unlock() + + if len(violations) > 0 { + t.Errorf("mock transport rejected request details:\n%s", strings.Join(violations, "\n")) + } + for path, route := range rt.routes { + want := route.expectedCalls + if want == 0 { + want = 1 + } + if got := counts[path]; got != want { + t.Errorf("request count for %s = %d, want %d", path, got, want) + } + delete(counts, path) + } + if len(counts) > 0 { + t.Errorf("unexpected request counts: %#v", counts) + } +} + +func sortedDownloadE2EKeys(values url.Values) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func TestDownloadFeaturesEndToEnd(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + t.Setenv("CLI_TOP_PROXY_MODE", "1") + t.Setenv("SEMESTER_CACHE", "") + t.Setenv("CSRF", downloadE2ECookies.CSRF) + + t.Run("ExecuteCoursePageDownload", func(t *testing.T) { + regNo := "E2E-DOWNLOAD-CONSOLIDATED" + pdf := []byte("%PDF-1.7\nconsolidated course material\n%%EOF\n") + transport := newDownloadE2ERoundTripper(map[string]downloadE2ERoute{ + "/vtop/academics/common/CoursePageConsolidated": { + body: []byte(``), + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "verifyMenu": "true", + }, + nonEmptyForm: []string{"x"}, + }, + "/vtop/academics/CoursePageConsolidated/getCourseDetail": { + body: []byte(` + + + + +
1Module 1
+ Lecture Notes.pdf + 1 +
1001 - Ada Lovelace - SCOPE10-Jul-2026
`), + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "CourseId": "COURSE-CONSOLIDATED", + "CoursType": "Theory", + }, + nonEmptyForm: []string{"x"}, + }, + "/vtop/downloadCourseMaterialFacultyPdf": { + body: pdf, + headers: http.Header{ + "Content-Disposition": {`attachment; filename="lecture-notes.pdf"`}, + "Content-Type": {"application/pdf"}, + }, + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "fileId": "FILE-CONSOLIDATED", + }, + }, + }) + installDownloadE2ETransport(t, transport, false) + + features.ExecuteCoursePageDownload(regNo, downloadE2ECookies, 1, 1, "1", "1") + + transport.assertDone(t) + hash := sha256.Sum256(pdf) + path := filepath.Join( + home, "Downloads", "CLI-TOP Downloads", "Course Page", + "Fall Semester 2025_CSE1001", "Theory", "Ada Lovelace", + fmt.Sprintf("Module-1_Lecture Notes_%x.pdf", hash[:4]), + ) + assertDownloadE2EFile(t, path, pdf) + }) + + t.Run("ExecuteCoursePageOldDownload", func(t *testing.T) { + regNo := "E2E-DOWNLOAD-OLD" + helpers.InvalidateSemesterCache(regNo) + t.Cleanup(func() { helpers.InvalidateSemesterCache(regNo) }) + + pdf := []byte("%PDF-1.7\nlegacy course material\n%%EOF\n") + transport := newDownloadE2ERoundTripper(map[string]downloadE2ERoute{ + "/vtop/academics/common/StudentAttendance": { + body: []byte(``), + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "verifyMenu": "true", + }, + nonEmptyForm: []string{"nocache"}, + }, + "/vtop/getCourseForCoursePage": { + body: []byte(``), + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "paramReturnId": "getCourseForCoursePage", + "semSubId": "SEM-OLD", + }, + nonEmptyForm: []string{"x"}, + }, + "/vtop/getSlotIdForCoursePage": { + body: []byte(``), + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "paramReturnId": "getSlotIdForCoursePage", + "semSubId": "SEM-OLD", + "classId": "CLASS-OLD", + "praType": "source", + }, + nonEmptyForm: []string{"x"}, + }, + "/vtop/getFacultyForCoursePage": { + body: []byte(` + + + +
1-----A11002 - Grace Hopper - SCOPE
`), + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "paramReturnId": "getFacultyForCoursePage", + "semSubId": "SEM-OLD", + "classId": "CLASS-OLD", + "slotId": "SLOT-OLD", + "praType": "source", + }, + nonEmptyForm: []string{"x"}, + }, + "/vtop/processViewStudentCourseDetail": { + body: []byte(` + + + + +
DateDay/SlotM.NoT.NoTopicReference Material
10-Jul-2026Mon/A111Graphs
`), + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "semSubId": "SEM-OLD", + "erpId": "ERP-OLD", + "classId": "CLASS-OLD", + }, + nonEmptyForm: []string{"x"}, + }, + "/vtop/downloadPdf": { + body: pdf, + headers: http.Header{ + "Content-Disposition": {`attachment; filename="graphs.pdf"`}, + "Content-Type": {"application/pdf"}, + }, + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "semSubId": "SEM-OLD", + "classId": "CLASS-OLD", + "materialId": "MAT-OLD", + "materialDate": "10-Jul-2026", + }, + nonEmptyForm: []string{"x"}, + }, + }) + installDownloadE2ETransport(t, transport, false) + + features.ExecuteCoursePageOldDownload(regNo, downloadE2ECookies, 1, 1, "1", "1") + + transport.assertDone(t) + path := filepath.Join( + home, "Downloads", "CLI-TOP Downloads", "Course Page", + "Algorithms_Theory_CSE2001", "A1_Grace-Hopper_SCOPE", "M1_T1_Graphs_1.pdf", + ) + assertDownloadE2EFile(t, path, pdf) + }) + + t.Run("ExecuteSyllabusDownload", func(t *testing.T) { + regNo := "E2E-DOWNLOAD-SYLLABUS" + pdf := []byte("%PDF-1.7\nsyllabus from zip\n%%EOF\n") + zippedPDF := makeDownloadE2EZip(t, "nested/syllabus.pdf", pdf) + transport := newDownloadE2ERoundTripper(map[string]downloadE2ERoute{ + "/vtop/academics/common/Curriculum": { + body: []byte(`
+
+
Programme Core
+
+
`), + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "verifyMenu": "true", + }, + nonEmptyForm: []string{"nocache"}, + }, + "/vtop/academics/common/curriculumCategoryView": { + body: []byte(` + +
1Data Structures
`), + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "categoryId": "CAT-CORE", + }, + nonEmptyForm: []string{"x"}, + }, + "/vtop/courseSyllabusDownload1": { + body: zippedPDF, + headers: http.Header{ + "Content-Disposition": {`attachment; filename="syllabus.zip"`}, + "Content-Type": {"application/zip"}, + }, + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "courseCode": "CSE1001", + }, + }, + }) + installDownloadE2ETransport(t, transport, false) + + features.ExecuteSyllabusDownload(regNo, downloadE2ECookies, "1") + + transport.assertDone(t) + path := filepath.Join(home, "Downloads", "CLI-TOP Downloads", "Syllabus", "Data Structures_CSE1001.pdf") + assertDownloadE2EFile(t, path, pdf) + }) + + t.Run("PrintAllDAs", func(t *testing.T) { + regNo := "E2E-DOWNLOAD-DA" + helpers.InvalidateSemesterCache(regNo) + t.Cleanup(func() { helpers.InvalidateSemesterCache(regNo) }) + + pdf := []byte("%PDF-1.7\ndigital assignment question paper\n%%EOF\n") + transport := newDownloadE2ERoundTripper(map[string]downloadE2ERoute{ + "/vtop/academics/common/StudentAttendance": { + body: []byte(``), + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "verifyMenu": "true", + }, + nonEmptyForm: []string{"nocache"}, + }, + "/vtop/examinations/doDigitalAssignment": { + body: []byte(` + +
1CLASS-DACSE3001Operating SystemsTheory
`), + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "semesterSubId": "SEM-DA", + }, + nonEmptyForm: []string{"x"}, + }, + "/vtop/examinations/processDigitalAssignment": { + body: []byte(` + + + + + + + + +
Sl.No.TitleDescriptionStart DateDue DateQPLast UpdatedUploadStatus
1Assignment OneProcesses01-Dec-201901-Jan-2020N/APending
`), + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "classId": "CLASS-DA", + }, + nonEmptyForm: []string{"x"}, + }, + "/vtop/examinations/doDownloadQuestion/": { + body: pdf, + headers: http.Header{ + "Content-Disposition": {`attachment; filename="assignment-one.pdf"`}, + "Content-Type": {"application/pdf"}, + }, + wantForm: map[string]string{ + "_csrf": downloadE2ECookies.CSRF, + "authorizedID": regNo, + "code": "QP-DA", + "classIdNumber": "CLASS-DA", + }, + nonEmptyForm: []string{"x"}, + }, + }) + installDownloadE2ETransport(t, transport, true) + + features.PrintAllDAs(regNo, downloadE2ECookies, "1", "1") + + transport.assertDone(t) + path := filepath.Join( + home, "Downloads", "CLI-TOP Downloads", "DA", + "CSE3001_Operating Systems", "Assignment One.pdf", + ) + assertDownloadE2EFile(t, path, pdf) + + icsPath := filepath.Join(home, "Downloads", "CLI-TOP Downloads", "Other Downloads", "ICS File", "All_DA_Deadlines.ics") + if _, err := os.Stat(icsPath); !os.IsNotExist(err) { + t.Fatalf("past-due DA fixture unexpectedly created %q (stat error: %v)", icsPath, err) + } + }) +} + +func installDownloadE2ETransport(t *testing.T, transport http.RoundTripper, replaceDefault bool) { + t.Helper() + + client := helpers.GetHTTPClient() + previousTransport := client.Transport + client.Transport = transport + t.Cleanup(func() { client.Transport = previousTransport }) + + if replaceDefault { + previousDefault := http.DefaultTransport + http.DefaultTransport = transport + t.Cleanup(func() { http.DefaultTransport = previousDefault }) + } +} + +func assertDownloadE2EFile(t *testing.T, path string, want []byte) { + t.Helper() + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read downloaded file %q: %v", path, err) + } + if !bytes.Equal(got, want) { + t.Fatalf("downloaded file %q = %q, want %q", path, got, want) + } +} + +func makeDownloadE2EZip(t *testing.T, name string, contents []byte) []byte { + t.Helper() + + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + file, err := writer.Create(name) + if err != nil { + t.Fatalf("create zip entry: %v", err) + } + if _, err := file.Write(contents); err != nil { + t.Fatalf("write zip entry: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close zip fixture: %v", err) + } + return buffer.Bytes() +} diff --git a/tests/feature_interactive_e2e_test.go b/tests/feature_interactive_e2e_test.go new file mode 100644 index 00000000..dd240a50 --- /dev/null +++ b/tests/feature_interactive_e2e_test.go @@ -0,0 +1,610 @@ +package tests + +import ( + "fmt" + "io" + "net/http" + "net/url" + "os" + "reflect" + "strings" + "testing" + + "cli-top/features" + "cli-top/helpers" + "cli-top/types" +) + +const interactiveFeatureRegNo = "24BCE0001" + +var interactiveFeatureCookies = types.Cookies{ + SERVERID: "fake-server", + CSRF: "fake-cookie-csrf", + JSESSIONID: "fake-session", +} + +type workflowRequest struct { + method string + path string + contentType string + cookie string + form url.Values +} + +type workflowResponder func(*workflowTransport, workflowRequest) (string, error) + +// workflowTransport is a stateful, in-memory stand-in for VTOP. Mutating +// workflows only change submitted; subsequent status responses read that state. +type workflowTransport struct { + requests []workflowRequest + submitted bool + responder workflowResponder + responseHeaders map[string]http.Header + errors []error +} + +func (transport *workflowTransport) RoundTrip(request *http.Request) (*http.Response, error) { + record, err := recordWorkflowRequest(request) + if err != nil { + transport.errors = append(transport.errors, err) + return nil, err + } + transport.requests = append(transport.requests, record) + + body, err := transport.responder(transport, record) + if err != nil { + transport.errors = append(transport.errors, err) + return nil, err + } + + headers := make(http.Header) + if responseHeaders := transport.responseHeaders[request.URL.Path]; responseHeaders != nil { + headers = responseHeaders.Clone() + } + + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: headers, + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + }, nil +} + +func recordWorkflowRequest(request *http.Request) (workflowRequest, error) { + form := url.Values{} + if request.Body != nil { + body, err := io.ReadAll(request.Body) + if err != nil { + return workflowRequest{}, fmt.Errorf("read %s request body: %w", request.URL.Path, err) + } + form, err = url.ParseQuery(string(body)) + if err != nil { + return workflowRequest{}, fmt.Errorf("parse %s request body: %w", request.URL.Path, err) + } + } + + return workflowRequest{ + method: request.Method, + path: request.URL.Path, + contentType: request.Header.Get("Content-Type"), + cookie: request.Header.Get("Cookie"), + form: form, + }, nil +} + +func TestInteractiveFeaturesEndToEnd(t *testing.T) { + t.Run("events select and register", testEventsWorkflow) + t.Run("facility select and register", testFacilityWorkflow) + t.Run("course allocation select and display", testCourseAllocationWorkflow) + t.Run("leave submit and verify", testLeaveWorkflow) + t.Run("nightslip submit and verify", testNightSlipWorkflow) +} + +func testEventsWorkflow(t *testing.T) { + t.Setenv("CLI_TOP_PROXY_MODE", "") + transport := installSharedWorkflowTransport(t, eventsWorkflowResponse) + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create stdin pipe: %v", err) + } + previousStdin := os.Stdin + os.Stdin = reader + t.Cleanup(func() { + os.Stdin = previousStdin + _ = writer.Close() + _ = reader.Close() + }) + if _, err := io.WriteString(writer, "1\n"); err != nil { + t.Fatalf("seed event selection: %v", err) + } + + var tables []helpers.TableSnapshot + confirmationSent := false + restoreCapture := helpers.RegisterTableCaptureHook(func(snapshot helpers.TableSnapshot) { + tables = append(tables, snapshot) + if !confirmationSent && len(snapshot.Headers) > 0 && snapshot.Headers[0] == "EVENT" { + confirmationSent = true + if _, err := io.WriteString(writer, "yes\n"); err != nil { + t.Errorf("send event confirmation: %v", err) + } + } + }) + t.Cleanup(restoreCapture) + + features.GetEvents(interactiveFeatureRegNo, interactiveFeatureCookies) + + assertWorkflowTransportClean(t, transport) + if !transport.submitted { + t.Fatal("event registration did not update mock VTOP state") + } + load := requireWorkflowRequest(t, transport, "/vtop/event/swf/loadEventRegistration", 1) + assertCommonWorkflowRequest(t, load, interactiveFeatureRegNo, interactiveFeatureCookies.CSRF) + if load.form.Get("verifyMenu") != "true" || load.form.Get("nocache") == "" { + t.Fatalf("event load form = %s, want verifyMenu and nocache", load.form.Encode()) + } + register := requireWorkflowRequest(t, transport, "/vtop/event/swf/registered/doEventRegistraiton", 1) + assertCommonWorkflowRequest(t, register, interactiveFeatureRegNo, interactiveFeatureCookies.CSRF) + if register.form.Get("eventId") != "EVT-E2E-1" || register.form.Get("x") == "" { + t.Fatalf("event registration form = %s, want selected event and timestamp", register.form.Encode()) + } + assertTableRow(t, tables, "EVENT", []string{"Mock Hack Night"}) +} + +func testFacilityWorkflow(t *testing.T) { + originalLatestJSONURL := helpers.GetLatestJSONURL() + t.Cleanup(func() { helpers.SetLatestJSONURL(originalLatestJSONURL) }) + t.Setenv("CLI_TOP_PROXY_MODE", "1") + t.Setenv("CLI_TOP_LATEST_JSON_URL", "https://mock.cli-top.test/latest.json") + helpers.SetLatestJSONURL(originalLatestJSONURL) + transport := installSharedWorkflowTransport(t, facilityWorkflowResponse) + + previousDefaultTransport := http.DefaultTransport + http.DefaultTransport = transport + t.Cleanup(func() { http.DefaultTransport = previousDefaultTransport }) + + var tables []helpers.TableSnapshot + restoreCapture := helpers.RegisterTableCaptureHook(func(snapshot helpers.TableSnapshot) { + tables = append(tables, snapshot) + }) + t.Cleanup(restoreCapture) + + if err := features.RegisterPhyFacility(interactiveFeatureRegNo, interactiveFeatureCookies, "Basketball", true); err != nil { + t.Fatalf("RegisterPhyFacility returned error: %v", err) + } + + assertWorkflowTransportClean(t, transport) + if !transport.submitted { + t.Fatal("facility registration did not update mock VTOP state") + } + latest := requireWorkflowRequest(t, transport, "/latest.json", 1) + if latest.method != http.MethodGet { + t.Fatalf("latest.json method = %q, want GET", latest.method) + } + available := requireWorkflowRequest(t, transport, "/vtop/phyedu/facilityAvailable", 3) + assertCommonWorkflowRequest(t, available, interactiveFeatureRegNo, interactiveFeatureCookies.CSRF) + registration := requireWorkflowRequest(t, transport, "/vtop/phyedu/PhyFacilityProcessRegistration", 1) + assertCommonWorkflowRequest(t, registration, interactiveFeatureRegNo, interactiveFeatureCookies.CSRF) + if registration.form.Get("facilityId") != "1" || registration.form.Get("miscId") != "9876" { + t.Fatalf("facility registration form = %s, want facilityId=1 and miscId=9876", registration.form.Encode()) + } + assertTableRow(t, tables, "NO.", []string{"Basketball", "Registered (Paid)"}) +} + +func testCourseAllocationWorkflow(t *testing.T) { + t.Setenv("CLI_TOP_PROXY_MODE", "1") + transport := installSharedWorkflowTransport(t, courseAllocationWorkflowResponse) + transport.responseHeaders = map[string]http.Header{ + "/vtop/academics/common/StudentRegistrationScheduleAllocation": { + "Set-Cookie": { + "JSESSIONID=allocation-session; Path=/; HttpOnly", + "ALLOCATION_ROUTE=allocation-node; Path=/vtop/; HttpOnly", + }, + }, + } + + var tables []helpers.TableSnapshot + restoreCapture := helpers.RegisterTableCaptureHook(func(snapshot helpers.TableSnapshot) { + tables = append(tables, snapshot) + }) + t.Cleanup(restoreCapture) + + features.ExecuteInteractiveCourseAllocationView( + interactiveFeatureRegNo, + interactiveFeatureCookies, + features.DefaultCourseAllocationPageURL, + "1", + "1", + ) + + assertWorkflowTransportClean(t, transport) + initial := requireWorkflowRequest(t, transport, "/vtop/academics/common/StudentRegistrationScheduleAllocation", 1) + assertCommonWorkflowRequest(t, initial, interactiveFeatureRegNo, interactiveFeatureCookies.CSRF) + if initial.form.Get("verifyMenu") != "true" || initial.form.Get("nocache") == "" { + t.Fatalf("course allocation initial form = %s, want verifyMenu and nocache", initial.form.Encode()) + } + courses := requireWorkflowRequest(t, transport, "/vtop/academics/common/getCoursesListForCurriculmCategory", 1) + assertCommonWorkflowRequestWithCookies(t, courses, "PAGE-AUTH", "page-csrf", []string{ + "JSESSIONID=allocation-session", + "SERVERID=fake-server", + "ALLOCATION_ROUTE=allocation-node", + }) + if strings.Contains(courses.cookie, "JSESSIONID=fake-session") { + t.Errorf("%s Cookie = %q, contains stale JSESSIONID", courses.path, courses.cookie) + } + if courses.form.Get("cccategory") != "CORE" { + t.Fatalf("course list form = %s, want selected category CORE", courses.form.Encode()) + } + details := requireWorkflowRequest(t, transport, "/vtop/academics/common/getCoursesDetailForRegistration", 1) + assertCommonWorkflowRequestWithCookies(t, details, "PAGE-AUTH", "page-csrf", []string{ + "JSESSIONID=allocation-session", + "SERVERID=fake-server", + "ALLOCATION_ROUTE=allocation-node", + }) + if details.form.Get("courseCode") != "CSE2001" { + t.Fatalf("course detail form = %s, want courseCode=CSE2001", details.form.Encode()) + } + + want := helpers.TableSnapshot{ + Headers: []string{"FACULTY", "VENUE", "SLOT", "TYPE"}, + Rows: [][]string{{"Dr Ada Lovelace", "SJT-101", "A1", "Theory"}}, + } + if len(tables) != 1 || !reflect.DeepEqual(tables[0], want) { + t.Fatalf("course allocation tables = %#v, want %#v", tables, []helpers.TableSnapshot{want}) + } +} + +func testLeaveWorkflow(t *testing.T) { + t.Setenv("CLI_TOP_PROXY_MODE", "1") + transport := installSharedWorkflowTransport(t, leaveWorkflowResponse) + + var tables []helpers.TableSnapshot + restoreCapture := helpers.RegisterTableCaptureHook(func(snapshot helpers.TableSnapshot) { + tables = append(tables, snapshot) + }) + t.Cleanup(restoreCapture) + + input := features.LeaveApplyInput{ + Apply: true, + LeaveCode: "HOME TOWN", + VisitingPlace: "Chennai", + Reason: "Family visit", + FromDate: "2027-01-02", + FromTime: "08:30 PM", + ToDate: "2027-01-03", + ToTime: "06:15", + } + if err := features.ExecuteLeave(interactiveFeatureRegNo, interactiveFeatureCookies, input); err != nil { + t.Fatalf("ExecuteLeave returned error: %v", err) + } + + assertWorkflowTransportClean(t, transport) + if !transport.submitted { + t.Fatal("leave submission did not update mock VTOP state") + } + requireWorkflowRequest(t, transport, "/vtop/hostels/student/leave/1", 2) + requireWorkflowRequest(t, transport, "/vtop/hostels/student/leave/4", 2) + requireWorkflowRequest(t, transport, "/vtop/hostels/student/leave/2", 1) + submit := requireWorkflowRequest(t, transport, "/vtop/hostels/student/leave/3", 1) + assertCommonWorkflowRequest(t, submit, interactiveFeatureRegNo, interactiveFeatureCookies.CSRF) + assertFormValues(t, submit.form, map[string]string{ + "leaveCode": "HT1", + "visitingPlace": "Chennai", + "reason": "Family visit", + "leaveFromDate": "2027-01-02", + "fromTime": "20:30", + "leaveToDate": "2027-01-03", + "toTime": "06:15", + "campusCode": "VLR", + "form": "LeaveRequestForm", + "control": "submitControl2", + }) + if submit.form.Get("x") == "" { + t.Fatal("leave submit form is missing its HTTP timestamp") + } + assertTableRow(t, tables, "VISIT PLACE", []string{"Chennai", "Family visit", "PENDING"}) +} + +func testNightSlipWorkflow(t *testing.T) { + t.Setenv("CLI_TOP_PROXY_MODE", "1") + transport := installSharedWorkflowTransport(t, nightSlipWorkflowResponse) + + var tables []helpers.TableSnapshot + restoreCapture := helpers.RegisterTableCaptureHook(func(snapshot helpers.TableSnapshot) { + tables = append(tables, snapshot) + }) + t.Cleanup(restoreCapture) + + input := features.NightSlipApplyInput{ + Apply: true, + CostCentreID: "CC-1", + AppliedTo: "WARDEN-7", + RoomTypeID: "ACADEMIC", + BuildingID: "SJT", + Venue: "SJT Portico", + LateHourEventID: "CLUB", + Details: "ACM board meeting", + FromDate: "2027-01-02", + FromTime: "20:30", + ToDate: "2027-01-02", + ToTime: "22:00", + } + if err := features.ExecuteNightSlip(interactiveFeatureRegNo, interactiveFeatureCookies, input); err != nil { + t.Fatalf("ExecuteNightSlip returned error: %v", err) + } + + assertWorkflowTransportClean(t, transport) + if !transport.submitted { + t.Fatal("nightslip submission did not update mock VTOP state") + } + requireWorkflowRequest(t, transport, "/vtop/hostels/late/hour/student/request/1", 2) + requireWorkflowRequest(t, transport, "/vtop/hostels/late/hour/student/request/9", 2) + requireWorkflowRequest(t, transport, "/vtop/hostels/late/hour/student/request/2", 1) + submit := requireWorkflowRequest(t, transport, "/vtop/hostels/late/hour/student/request/8", 1) + assertCommonWorkflowRequest(t, submit, interactiveFeatureRegNo, interactiveFeatureCookies.CSRF) + assertFormValues(t, submit.form, map[string]string{ + "costCentreId": "CC-1", + "appliedTo": "WARDEN-7", + "roomTypeId": "ACADEMIC", + "buildingId": "SJT", + "venue": "SJT Portico", + "lateHourEventId": "CLUB", + "details": "ACM board meeting", + "lateHourFromDate": "2027-01-02", + "fromTime": "08:30 PM", + "lateHourToDate": "2027-01-02", + "toTime": "10:00 PM", + "hostelId": "HOSTEL-1", + "form": "LateHourRequestForm", + "control": "submitButton1", + }) + if submit.form.Get("x") == "" { + t.Fatal("nightslip submit form is missing its HTTP timestamp") + } + assertTableRow(t, tables, "VENUE", []string{"SJT Portico", "ACM board meeting", "APPROVAL PENDING"}) +} + +func installSharedWorkflowTransport(t *testing.T, responder workflowResponder) *workflowTransport { + t.Helper() + transport := &workflowTransport{responder: responder} + client := helpers.GetHTTPClient() + previousTransport := client.Transport + client.Transport = transport + t.Cleanup(func() { client.Transport = previousTransport }) + return transport +} + +func assertWorkflowTransportClean(t *testing.T, transport *workflowTransport) { + t.Helper() + if len(transport.errors) != 0 { + t.Fatalf("mock transport errors: %v", transport.errors) + } +} + +func requireWorkflowRequest(t *testing.T, transport *workflowTransport, path string, wantCount int) workflowRequest { + t.Helper() + var matches []workflowRequest + for _, request := range transport.requests { + if request.path == path { + matches = append(matches, request) + } + } + if len(matches) != wantCount { + t.Fatalf("request count for %s = %d, want %d; all requests: %#v", path, len(matches), wantCount, transport.requests) + } + return matches[len(matches)-1] +} + +func assertCommonWorkflowRequest(t *testing.T, request workflowRequest, regNo string, csrf string) { + t.Helper() + assertCommonWorkflowRequestWithCookies(t, request, regNo, csrf, []string{ + "JSESSIONID=fake-session", + "SERVERID=fake-server", + }) +} + +func assertCommonWorkflowRequestWithCookies(t *testing.T, request workflowRequest, regNo string, csrf string, cookies []string) { + t.Helper() + if request.method != http.MethodPost { + t.Errorf("%s method = %q, want POST", request.path, request.method) + } + if request.contentType != "application/x-www-form-urlencoded" { + t.Errorf("%s Content-Type = %q, want application/x-www-form-urlencoded", request.path, request.contentType) + } + if request.form.Get("authorizedID") != regNo { + t.Errorf("%s authorizedID = %q, want %q", request.path, request.form.Get("authorizedID"), regNo) + } + if request.form.Get("_csrf") != csrf { + t.Errorf("%s _csrf = %q, want %q", request.path, request.form.Get("_csrf"), csrf) + } + for _, cookie := range cookies { + if !strings.Contains(request.cookie, cookie) { + t.Errorf("%s Cookie = %q, want %q", request.path, request.cookie, cookie) + } + } +} + +func assertFormValues(t *testing.T, form url.Values, want map[string]string) { + t.Helper() + for key, value := range want { + if form.Get(key) != value { + t.Errorf("form[%q] = %q, want %q; form: %s", key, form.Get(key), value, form.Encode()) + } + } +} + +func assertTableRow(t *testing.T, tables []helpers.TableSnapshot, firstHeader string, cells []string) { + t.Helper() + for _, table := range tables { + if len(table.Headers) == 0 || table.Headers[0] != firstHeader { + continue + } + for _, row := range table.Rows { + joined := helpers.StripAnsiCodes(strings.Join(row, " | ")) + matched := true + for _, cell := range cells { + if !strings.Contains(joined, cell) { + matched = false + break + } + } + if matched { + return + } + } + } + t.Fatalf("no %q table row contained %q; tables: %#v", firstHeader, cells, tables) +} + +func eventsWorkflowResponse(transport *workflowTransport, request workflowRequest) (string, error) { + if request.method != http.MethodPost { + return "", fmt.Errorf("events: unexpected method %s for %s", request.method, request.path) + } + switch request.path { + case "/vtop/event/swf/loadEventRegistration": + return eventPageHTML(false), nil + case "/vtop/event/swf/registered/doEventRegistraiton": + transport.submitted = true + return eventPageHTML(true), nil + default: + return "", fmt.Errorf("events: unexpected request path %s", request.path) + } +} + +func eventPageHTML(registered bool) string { + status := `` + tableID := "dataTable1" + message := "" + if registered { + status = "Registered" + tableID = "dataTable2" + message = `
Registered successfully
` + } + return fmt.Sprintf(`%s + + + + + + + +
1ACM-VIT (CHAPTER)Mock Hack Night31-Dec-209931-Dec-209906:00 PM09:00 PM-01-Jan-202730-Dec-2099SJT 101 (OFFLINE)%s
`, message, tableID, status) +} + +func facilityWorkflowResponse(transport *workflowTransport, request workflowRequest) (string, error) { + switch request.path { + case "/latest.json": + if request.method != http.MethodGet { + return "", fmt.Errorf("facility: latest.json method %s", request.method) + } + return `{"version":"test","killSwitch":0}`, nil + case "/vtop/phyedu/facilityAvailable": + if request.method != http.MethodPost { + return "", fmt.Errorf("facility: availability method %s", request.method) + } + return facilityPageHTML(transport.submitted), nil + case "/vtop/phyedu/PhyFacilityProcessRegistration": + if request.method != http.MethodPost { + return "", fmt.Errorf("facility: registration method %s", request.method) + } + transport.submitted = true + return facilityPageHTML(true), nil + default: + return "", fmt.Errorf("facility: unexpected request path %s", request.path) + } +} + +func facilityPageHTML(registered bool) string { + registrationRow := "" + if registered { + registrationRow = `BasketballPaid` + } + return fmt.Sprintf(` + + + +
Facility NameFeesSeatsAction
Basketball11804
+
My Registration(s)
+
%s
`, registrationRow) +} + +func courseAllocationWorkflowResponse(_ *workflowTransport, request workflowRequest) (string, error) { + if request.method != http.MethodPost { + return "", fmt.Errorf("course allocation: unexpected method %s for %s", request.method, request.path) + } + switch request.path { + case "/vtop/academics/common/StudentRegistrationScheduleAllocation": + return ` + + +
` + strings.Repeat("mock allocation page padding ", 30) + `
+ `, nil + case "/vtop/academics/common/getCoursesListForCurriculmCategory": + return ``, nil + case "/vtop/academics/common/getCoursesDetailForRegistration": + return `
+ +
A1SJT-101Dr Ada LovelaceTheory
`, nil + default: + return "", fmt.Errorf("course allocation: unexpected request path %s", request.path) + } +} + +func leaveWorkflowResponse(transport *workflowTransport, request workflowRequest) (string, error) { + if request.method != http.MethodPost { + return "", fmt.Errorf("leave: unexpected method %s for %s", request.method, request.path) + } + switch request.path { + case "/vtop/hostels/student/leave/1": + return ``, nil + case "/vtop/hostels/student/leave/2": + return `
+ + + +
`, nil + case "/vtop/hostels/student/leave/3": + transport.submitted = true + return `
Leave request submitted successfully.
`, nil + case "/vtop/hostels/student/leave/4": + if !transport.submitted { + return `
`, nil + } + return ` + + +
ChennaiFamily visitHOME TOWN02-Jan-2027 20:3003-Jan-2027 06:15REQUEST RAISED-PENDING
`, nil + default: + return "", fmt.Errorf("leave: unexpected request path %s", request.path) + } +} + +func nightSlipWorkflowResponse(transport *workflowTransport, request workflowRequest) (string, error) { + if request.method != http.MethodPost { + return "", fmt.Errorf("nightslip: unexpected method %s for %s", request.method, request.path) + } + switch request.path { + case "/vtop/hostels/late/hour/student/request/1": + return ``, nil + case "/vtop/hostels/late/hour/student/request/2": + return `
+ + +
`, nil + case "/vtop/hostels/late/hour/student/request/8": + transport.submitted = true + return `
Nightslip request submitted successfully.
`, nil + case "/vtop/hostels/late/hour/student/request/9": + if !transport.submitted { + return `
`, nil + } + return ` + + + +
SJT PorticoClubs and ChaptersACM board meetingDr Warden02-Jan-202702-Jan-202720:30 to 22:00REQUEST RAISED-APPROVAL PENDING
`, nil + default: + return "", fmt.Errorf("nightslip: unexpected request path %s", request.path) + } +} diff --git a/tests/feature_planner_e2e_test.go b/tests/feature_planner_e2e_test.go new file mode 100644 index 00000000..56237c31 --- /dev/null +++ b/tests/feature_planner_e2e_test.go @@ -0,0 +1,431 @@ +package tests + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "cli-top/features" + "cli-top/helpers" + "cli-top/types" +) + +const ( + plannerSemesterID = "SEM-PLANNER-E2E" + plannerClassGroup = "COMB" +) + +var plannerFeatureCookies = types.Cookies{ + SERVERID: "planner-fake-server", + CSRF: "planner-fake-csrf", + JSESSIONID: "planner-fake-session", +} + +type plannerFeatureRequest struct { + path string + form url.Values +} + +type plannerFeatureTransport struct { + regNo string + responses map[string]func(url.Values) string + + mu sync.Mutex + requests []plannerFeatureRequest + errors []string +} + +func (transport *plannerFeatureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, fmt.Errorf("read %s request body: %w", req.URL.Path, err) + } + form, err := url.ParseQuery(string(body)) + if err != nil { + return nil, fmt.Errorf("parse %s request body: %w", req.URL.Path, err) + } + + transport.mu.Lock() + transport.requests = append(transport.requests, plannerFeatureRequest{path: req.URL.Path, form: form}) + if req.Method != http.MethodPost { + transport.errors = append(transport.errors, fmt.Sprintf("%s used method %s", req.URL.Path, req.Method)) + } + if got := req.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" { + transport.errors = append(transport.errors, fmt.Sprintf("%s used content type %q", req.URL.Path, got)) + } + cookie := req.Header.Get("Cookie") + if !strings.Contains(cookie, "SERVERID="+plannerFeatureCookies.SERVERID) || + !strings.Contains(cookie, "JSESSIONID="+plannerFeatureCookies.JSESSIONID) { + transport.errors = append(transport.errors, fmt.Sprintf("%s used cookie %q", req.URL.Path, cookie)) + } + if got := form.Get("authorizedID"); got != transport.regNo { + transport.errors = append(transport.errors, fmt.Sprintf("%s authorizedID = %q", req.URL.Path, got)) + } + if got := form.Get("_csrf"); got != plannerFeatureCookies.CSRF { + transport.errors = append(transport.errors, fmt.Sprintf("%s _csrf = %q", req.URL.Path, got)) + } + handler := transport.responses[req.URL.Path] + transport.mu.Unlock() + + if handler == nil { + return nil, fmt.Errorf("unexpected request path %q", req.URL.Path) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(handler(form))), + Request: req, + }, nil +} + +func TestPlannerFeaturesEndToEnd(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CLI_TOP_PROXY_MODE", "1") + t.Setenv("SEMESTER_CACHE", "") + + t.Run("GetTimeTable", func(t *testing.T) { + regNo := "E2E-PLANNER-TIMETABLE" + locIndia := time.FixedZone("IST", 5*60*60+30*60) + now := time.Now().In(locIndia) + daysUntilSaturday := (int(time.Saturday) - int(now.Weekday()) + 7) % 7 + workingSaturday := now.AddDate(0, 0, daysUntilSaturday) + calendarDate := plannerCalendarDate(workingSaturday) + calendarHTML := plannerCalendarHTML(workingSaturday.Year(), workingSaturday.Month(), map[int][]string{ + workingSaturday.Day(): {"Instructional Day", "Monday Day Order"}, + }) + + transport, tables, _ := runPlannerFeature(t, regNo, map[string]func(url.Values) string{ + "/vtop/academics/common/StudentAttendance": plannerStaticResponse(plannerSemesterHTML()), + "/vtop/processViewTimeTable": plannerStaticResponse(plannerTimetableHTML("A1")), + "/vtop/getDateForSemesterPreview": plannerStaticResponse(plannerClassGroupHTML()), + "/vtop/getListForSemester": plannerStaticResponse(plannerDateListHTML(calendarDate)), + "/vtop/processViewCalendar": plannerStaticResponse(calendarHTML), + }, func() { + features.GetTimeTable(regNo, plannerFeatureCookies, 1) + }) + + assertPlannerPathCounts(t, transport, map[string]int{ + "/vtop/academics/common/StudentAttendance": 1, + "/vtop/processViewTimeTable": 1, + "/vtop/getDateForSemesterPreview": 1, + "/vtop/getListForSemester": 1, + "/vtop/processViewCalendar": 1, + }) + assertPlannerPostedValue(t, transport, "/vtop/processViewTimeTable", "semesterSubId", plannerSemesterID) + assertPlannerPostedValue(t, transport, "/vtop/processViewCalendar", "calDate", calendarDate) + assertPlannerTableContains(t, tables, []string{"TIME", "SUBJECT", "SLOT", "VENUE"}, []string{"08:00-08:50", "Algorithms", "A1", "SJT-101"}) + }) + + t.Run("PrintCal", func(t *testing.T) { + regNo := "E2E-PLANNER-CALENDAR" + calendarDate := "01-JAN-2030" + transport, tables, output := runPlannerFeature(t, regNo, map[string]func(url.Values) string{ + "/vtop/academics/common/StudentAttendance": plannerStaticResponse(plannerSemesterHTML()), + "/vtop/getDateForSemesterPreview": plannerStaticResponse(plannerClassGroupHTML()), + "/vtop/getListForSemester": plannerStaticResponse(plannerDateListHTML(calendarDate)), + "/vtop/processViewCalendar": plannerStaticResponse(plannerCalendarHTML(2030, time.January, map[int][]string{ + 1: {"Holiday", "New Year's Day"}, + 2: {"Exam Day"}, + })), + }, func() { + features.PrintCal(regNo, plannerFeatureCookies, 1, 1) + }) + + assertPlannerPathCounts(t, transport, map[string]int{ + "/vtop/academics/common/StudentAttendance": 1, + "/vtop/getDateForSemesterPreview": 1, + "/vtop/getListForSemester": 1, + "/vtop/processViewCalendar": 1, + }) + assertPlannerPostedValue(t, transport, "/vtop/getListForSemester", "classGroupId", plannerClassGroup) + if len(tables) != 0 { + t.Fatalf("PrintCal captured unexpected tables: %#v", tables) + } + plainOutput := helpers.StripAnsiCodes(output) + for _, want := range []string{"Your selected class group: Combined", "Red-Exam Day", "Blue-Holiday", "2030", "JAN", "Su Mo Tu We Th Fr Sa"} { + if !strings.Contains(plainOutput, want) { + t.Errorf("PrintCal output = %q, want %q", plainOutput, want) + } + } + }) + + t.Run("GetHolidayList", func(t *testing.T) { + regNo := "E2E-PLANNER-HOLIDAYS" + holiday := time.Date(2099, time.December, 31, 0, 0, 0, 0, time.FixedZone("IST", 5*60*60+30*60)) + calendarDate := plannerCalendarDate(holiday) + transport, tables, _ := runPlannerFeature(t, regNo, map[string]func(url.Values) string{ + "/vtop/academics/common/StudentAttendance": plannerStaticResponse(plannerSemesterHTML()), + "/vtop/getDateForSemesterPreview": plannerStaticResponse(plannerClassGroupHTML()), + "/vtop/getListForSemester": plannerStaticResponse(plannerDateListHTML(calendarDate)), + "/vtop/processViewCalendar": plannerStaticResponse(plannerCalendarHTML(holiday.Year(), holiday.Month(), map[int][]string{ + holiday.Day(): {"Holiday", "Founders Day"}, + })), + "/vtop/processViewTimeTable": plannerStaticResponse(plannerTimetableHTML("B1")), + }, func() { + features.GetHolidayList(regNo, plannerFeatureCookies, 1, 1) + }) + + assertPlannerPathCounts(t, transport, map[string]int{ + "/vtop/academics/common/StudentAttendance": 1, + "/vtop/getDateForSemesterPreview": 1, + "/vtop/getListForSemester": 1, + "/vtop/processViewCalendar": 1, + "/vtop/processViewTimeTable": 1, + }) + if len(tables) != 1 { + t.Fatalf("holiday table count = %d, want 1: %#v", len(tables), tables) + } + if !reflect.DeepEqual(tables[0].Headers, []string{"IN", "DATE", "DAY", "TYPE", "REASON"}) || len(tables[0].Rows) != 1 { + t.Fatalf("unexpected holiday table: %#v", tables[0]) + } + wantSuffix := []string{"31 Dec 2099", "Thursday", "Holiday", "Founders Day"} + if !reflect.DeepEqual(tables[0].Rows[0][1:], wantSuffix) { + t.Fatalf("holiday row = %#v, want suffix %#v", tables[0].Rows[0], wantSuffix) + } + }) + + planningCases := []struct { + name string + label string + offset int + run func(string) + }{ + {name: "GetToday", label: "Today", offset: 0, run: func(regNo string) { features.GetToday(regNo, plannerFeatureCookies, 1, 1) }}, + {name: "GetTomorrow", label: "Tomorrow", offset: 1, run: func(regNo string) { features.GetTomorrow(regNo, plannerFeatureCookies, 1, 1) }}, + {name: "GetDayAfter", label: "Day After Tomorrow", offset: 2, run: func(regNo string) { features.GetDayAfter(regNo, plannerFeatureCookies, 1, 1) }}, + } + + for index, testCase := range planningCases { + t.Run(testCase.name, func(t *testing.T) { + regNo := fmt.Sprintf("E2E-PLANNER-DAY-%d", index) + locIndia := time.FixedZone("IST", 5*60*60+30*60) + target := features.ResolvePlanningDate(time.Now().In(locIndia), testCase.offset) + calendarDate := plannerCalendarDate(target) + transport, tables, output := runPlannerFeature(t, regNo, map[string]func(url.Values) string{ + "/vtop/academics/common/StudentAttendance": plannerStaticResponse(plannerSemesterHTML()), + "/vtop/getDateForSemesterPreview": plannerStaticResponse(plannerClassGroupHTML()), + "/vtop/getListForSemester": plannerStaticResponse(plannerDateListHTML(calendarDate)), + "/vtop/processViewCalendar": plannerStaticResponse(plannerCalendarHTML(target.Year(), target.Month(), map[int][]string{ + target.Day(): {"Instructional Day", "Monday Day Order", "E2E planning schedule"}, + })), + "/vtop/examinations/doSearchExamScheduleForStudent": plannerStaticResponse(plannerExamHTML(target)), + "/vtop/processViewTimeTable": plannerStaticResponse(plannerTimetableHTML("A1")), + "/vtop/processViewStudentAttendance": plannerStaticResponse(plannerAttendanceHTML()), + }, func() { + testCase.run(regNo) + }) + + assertPlannerPathCounts(t, transport, map[string]int{ + "/vtop/academics/common/StudentAttendance": 1, + "/vtop/getDateForSemesterPreview": 1, + "/vtop/getListForSemester": 1, + "/vtop/processViewCalendar": 1, + "/vtop/examinations/doSearchExamScheduleForStudent": 1, + "/vtop/processViewTimeTable": 1, + "/vtop/processViewStudentAttendance": 1, + }) + assertPlannerPostedValue(t, transport, "/vtop/processViewStudentAttendance", "semesterSubId", plannerSemesterID) + assertPlannerTableContains(t, tables, []string{"COURSE", "CATEGORY", "TIME", "VENUE"}, []string{"Algorithms", "CAT1", "09:00", "SJT-201"}) + assertPlannerTableContains(t, tables, []string{"TIME", "SUBJECT", "VENUE", "%", "LEVERAGE", "SKIP?"}, []string{"08:00-08:50", "Algorithms", "SJT-101", "90%", "Can miss 2 class(es)", "Yes"}) + + plainOutput := helpers.StripAnsiCodes(output) + for _, want := range []string{ + testCase.label + ": " + target.Format("Monday, 02 Jan 2006"), + "Calendar: E2E planning schedule", + fmt.Sprintf("Skippable %s: 1/1 classes.", strings.ToLower(testCase.label)), + } { + if !strings.Contains(plainOutput, want) { + t.Errorf("%s output = %q, want %q", testCase.name, plainOutput, want) + } + } + }) + } + + err := filepath.WalkDir(home, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() && strings.EqualFold(filepath.Ext(path), ".ics") { + return fmt.Errorf("unexpected ICS file %s", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func runPlannerFeature(t *testing.T, regNo string, responses map[string]func(url.Values) string, invoke func()) (*plannerFeatureTransport, []helpers.TableSnapshot, string) { + t.Helper() + + helpers.InvalidateSemesterCache(regNo) + t.Cleanup(func() { helpers.InvalidateSemesterCache(regNo) }) + + transport := &plannerFeatureTransport{regNo: regNo, responses: responses} + client := helpers.GetHTTPClient() + previousTransport := client.Transport + client.Transport = transport + t.Cleanup(func() { client.Transport = previousTransport }) + + var tables []helpers.TableSnapshot + restoreCapture := helpers.RegisterTableCaptureHook(func(snapshot helpers.TableSnapshot) { + tables = append(tables, snapshot) + }) + t.Cleanup(restoreCapture) + + output := capturePlannerFeatureOutput(t, invoke) + transport.mu.Lock() + defer transport.mu.Unlock() + if len(transport.errors) > 0 { + t.Fatalf("request validation errors: %s", strings.Join(transport.errors, "; ")) + } + return transport, tables, output +} + +func assertPlannerPathCounts(t *testing.T, transport *plannerFeatureTransport, want map[string]int) { + t.Helper() + transport.mu.Lock() + defer transport.mu.Unlock() + + got := make(map[string]int) + for _, request := range transport.requests { + got[request.path]++ + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("request path counts = %#v, want %#v", got, want) + } +} + +func assertPlannerPostedValue(t *testing.T, transport *plannerFeatureTransport, path, key, want string) { + t.Helper() + transport.mu.Lock() + defer transport.mu.Unlock() + + for _, request := range transport.requests { + if request.path == path { + if got := request.form.Get(key); got != want { + t.Fatalf("%s %s = %q, want %q", path, key, got, want) + } + return + } + } + t.Fatalf("no request captured for %s", path) +} + +func assertPlannerTableContains(t *testing.T, tables []helpers.TableSnapshot, headers, row []string) { + t.Helper() + for _, table := range tables { + if !reflect.DeepEqual(table.Headers, headers) { + continue + } + for _, candidate := range table.Rows { + if reflect.DeepEqual(candidate, row) { + return + } + } + } + t.Fatalf("captured tables %#v do not contain headers %#v and row %#v", tables, headers, row) +} + +func plannerStaticResponse(body string) func(url.Values) string { + return func(url.Values) string { return body } +} + +func plannerSemesterHTML() string { + return `` +} + +func plannerClassGroupHTML() string { + return `` +} + +func plannerDateListHTML(calendarDate string) string { + return `Month` +} + +func plannerCalendarDate(date time.Time) string { + return strings.ToUpper(date.Format("01-Jan-2006")) +} + +func plannerCalendarHTML(year int, month time.Month, overrides map[int][]string) string { + var html strings.Builder + html.WriteString(``) + daysInMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day() + for day := 1; day <= daysInMonth; day++ { + labels := overrides[day] + if len(labels) == 0 { + labels = []string{"Instructional Day"} + } + fmt.Fprintf(&html, "") + } + html.WriteString(`
%d", day) + for _, label := range labels { + fmt.Fprintf(&html, "%s", label) + } + html.WriteString("
`) + return html.String() +} + +func plannerTimetableHTML(slot string) string { + return `` + + `` + + `` + + `
1-CSE1001 - Algorithms----` + slot + ` - SJT-101
` +} + +func plannerAttendanceHTML() string { + return `` + + `` + + `` + + `` + + `
1-CSE1001 - Algorithms - Theory Only-DR ADA LOVELACE - 100191090%
` +} + +func plannerExamHTML(date time.Time) string { + return `` + + `` + + `` + + `` + + `` + + `
CAT1
1CSE1001Algorithms--A1` + date.Format("02-Jan-2006") + `--09:00SJT-201A21
` +} + +func capturePlannerFeatureOutput(t *testing.T, invoke func()) (output string) { + t.Helper() + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create stdout pipe: %v", err) + } + previousStdout := os.Stdout + os.Stdout = writer + + done := make(chan string, 1) + go func() { + var captured bytes.Buffer + _, _ = io.Copy(&captured, reader) + done <- captured.String() + }() + + defer func() { + os.Stdout = previousStdout + _ = writer.Close() + output = <-done + _ = reader.Close() + }() + + invoke() + return output +} diff --git a/tests/feature_simple_e2e_test.go b/tests/feature_simple_e2e_test.go new file mode 100644 index 00000000..7c475500 --- /dev/null +++ b/tests/feature_simple_e2e_test.go @@ -0,0 +1,324 @@ +package tests + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/url" + "os" + "reflect" + "strings" + "testing" + + "cli-top/features" + "cli-top/helpers" + "cli-top/types" +) + +const simpleFeatureRegNo = "24BCE0001" + +var simpleFeatureCookies = types.Cookies{ + CSRF: "fake-csrf-token", + JSESSIONID: "fake-session-id", + SERVERID: "fake-server-id", +} + +type simpleFeatureRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f simpleFeatureRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type simpleFeatureRequest struct { + method string + path string + contentType string + cookie string + form url.Values +} + +func TestSimpleReadOnlyFeaturesEndToEnd(t *testing.T) { + t.Setenv("CLI_TOP_PROXY_MODE", "1") + + t.Run("Profile", func(t *testing.T) { + html := ` + + + + + ` + + request, snapshots, _ := runSimpleFeature(t, + "/vtop/studentsRecord/StudentProfileAllView", + html, + func() { features.Profile(simpleFeatureCookies, simpleFeatureRegNo) }, + ) + + assertSimpleFeatureRequest(t, request, true) + assertSimpleFeatureSnapshots(t, snapshots, helpers.TableSnapshot{ + Headers: []string{"FIELD", "INFORMATION"}, + Rows: [][]string{ + {"Register Number", "24BCE0001"}, + {"Program & Branch", "B.Tech. Computer Science and Engineering"}, + {"VIT Email", "student@vitstudent.ac.in"}, + {"School Name", "School of Computer Science and Engineering"}, + }, + }) + }) + + t.Run("PrintHostelInfo", func(t *testing.T) { + html := `
+ + + + + + +
Application NumberHOSTEL-2026-001
BlockQ Block
Room412
Bed Type4 Bed AC
MessSpecial
StatusAllotted
` + endpoint := "https://vtop.vit.ac.in/vtop/studentsRecord/StudentProfileAllView" + + request, snapshots, output := runSimpleFeature(t, + "/vtop/studentsRecord/StudentProfileAllView", + html, + func() { features.PrintHostelInfo(simpleFeatureRegNo, simpleFeatureCookies, endpoint) }, + ) + + assertSimpleFeatureRequest(t, request, true) + assertSimpleFeatureSnapshots(t, snapshots, helpers.TableSnapshot{ + Headers: []string{"FIELD", "INFORMATION"}, + Rows: [][]string{ + {"Block", "Q Block"}, + {"Room", "412"}, + {"Bed Type", "4 Bed AC"}, + {"Mess", "Special"}, + {"Status", "Allotted"}, + }, + }) + if !strings.Contains(output, "Student Accommodation Info") { + t.Fatalf("output = %q, want accommodation heading", output) + } + }) + + t.Run("PrintCgpa", func(t *testing.T) { + html := `
+ + +
1601549.421218821000
` + endpoint := "https://vtop.vit.ac.in/vtop/examinations/examGradeView/StudentGradeHistory" + + request, snapshots, output := runSimpleFeature(t, + "/vtop/examinations/examGradeView/StudentGradeHistory", + html, + func() { features.PrintCgpa(simpleFeatureRegNo, simpleFeatureCookies, endpoint) }, + ) + + assertSimpleFeatureRequest(t, request, true) + assertSimpleFeatureSnapshots(t, snapshots, helpers.TableSnapshot{ + Headers: []string{"GRADE", "COUNT"}, + Rows: [][]string{ + {"S Grades", "12"}, + {"A Grades", "18"}, + {"B Grades", "8"}, + {"C Grades", "2"}, + {"D Grades", "1"}, + {"E Grades", "0"}, + {"F Grades", "0"}, + {"N Grades", "0"}, + }, + }) + for _, want := range []string{"Credits Registered: 160", "Credits Earned: 154", "CGPA: \x1b[32m9.42\x1b[0m"} { + if !strings.Contains(output, want) { + t.Errorf("output = %q, want %q", output, want) + } + } + }) + + t.Run("GetReceipt", func(t *testing.T) { + html := ` + + +
Invoice NumberReceipt NumberDateAmountView
INV-2026-001RCPT-00101-Jul-202695000.00VIEW
` + + request, snapshots, _ := runSimpleFeature(t, + "/vtop/finance/getStudentReceipts", + html, + func() { features.GetReceipt(simpleFeatureRegNo, simpleFeatureCookies) }, + ) + + assertSimpleFeatureRequest(t, request, true) + assertSimpleFeatureSnapshots(t, snapshots, helpers.TableSnapshot{ + Headers: []string{"INDEX", "INVOICE NUMBER", "RECEIPT NUMBER", "DATE", "AMOUNT"}, + Rows: [][]string{{"1", "INV-2026-001", "RCPT-001", "01-Jul-2026", "95000.00"}}, + HasIndex: true, + }) + }) + + t.Run("GetLibraryDues", func(t *testing.T) { + html := ` + + +
Overdue book fine250.00
Lost library card100.00
` + + request, snapshots, _ := runSimpleFeature(t, + "/vtop/finance/libraryPayments", + html, + func() { features.GetLibraryDues(simpleFeatureRegNo, simpleFeatureCookies) }, + ) + + assertSimpleFeatureRequest(t, request, true) + assertSimpleFeatureSnapshots(t, snapshots, helpers.TableSnapshot{ + Headers: []string{"INDEX", "TYPE", "AMOUNT"}, + Rows: [][]string{ + {"1", "Overdue book fine", "250.00"}, + {"2", "Lost library card", "100.00"}, + }, + HasIndex: true, + }) + }) + + t.Run("GetClassMessage", func(t *testing.T) { + html := ` +
CSE2001 - Data Structures and AlgorithmsBring your lab record for evaluation on Monday.
+
MAT2001 - Linear Algebra - Online CourseQuiz 2 opens at 6 PM and closes at 7 PM.
+ ` + + request, snapshots, _ := runSimpleFeature(t, + "/vtop/academics/common/StudentClassMessage", + html, + func() { features.GetClassMessage(simpleFeatureRegNo, simpleFeatureCookies) }, + ) + + assertSimpleFeatureRequest(t, request, false) + if request.form.Get("x") == "" { + t.Fatal("class message request is missing its UTC cache-buster") + } + assertSimpleFeatureSnapshots(t, snapshots, helpers.TableSnapshot{ + Headers: []string{"INDEX", "COURSE", "MESSAGE"}, + Rows: [][]string{ + {"1", "Data Structures and Algorithms", "Bring your lab record for evaluation on Monday."}, + {"2", "Linear Algebra", "Quiz 2 opens at 6 PM and closes at 7 PM."}, + }, + HasIndex: true, + }) + }) +} + +func runSimpleFeature(t *testing.T, expectedPath, html string, invoke func()) (simpleFeatureRequest, []helpers.TableSnapshot, string) { + t.Helper() + + client := helpers.GetHTTPClient() + previousTransport := client.Transport + var requests []simpleFeatureRequest + client.Transport = simpleFeatureRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != expectedPath { + return nil, fmt.Errorf("unexpected request path %q; want %q", req.URL.Path, expectedPath) + } + + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, fmt.Errorf("read request body: %w", err) + } + form, err := url.ParseQuery(string(body)) + if err != nil { + return nil, fmt.Errorf("parse request body: %w", err) + } + requests = append(requests, simpleFeatureRequest{ + method: req.Method, + path: req.URL.Path, + contentType: req.Header.Get("Content-Type"), + cookie: req.Header.Get("Cookie"), + form: form, + }) + + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(html)), + Request: req, + }, nil + }) + t.Cleanup(func() { client.Transport = previousTransport }) + + var snapshots []helpers.TableSnapshot + restoreCapture := helpers.RegisterTableCaptureHook(func(snapshot helpers.TableSnapshot) { + snapshots = append(snapshots, snapshot) + }) + t.Cleanup(restoreCapture) + + output := captureSimpleFeatureOutput(t, invoke) + if len(requests) != 1 { + t.Fatalf("request count = %d, want 1", len(requests)) + } + return requests[0], snapshots, output +} + +func assertSimpleFeatureRequest(t *testing.T, request simpleFeatureRequest, expectVerifyMenu bool) { + t.Helper() + + if request.method != http.MethodPost { + t.Errorf("request method = %q, want POST", request.method) + } + if request.path == "" { + t.Error("request path is empty") + } + if request.contentType != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", request.contentType) + } + if request.cookie != "SERVERID=fake-server-id; JSESSIONID=fake-session-id" { + t.Errorf("Cookie = %q, want fake VTOP session cookies", request.cookie) + } + if got := request.form.Get("authorizedID"); got != simpleFeatureRegNo { + t.Errorf("authorizedID = %q, want %q", got, simpleFeatureRegNo) + } + if got := request.form.Get("_csrf"); got != simpleFeatureCookies.CSRF { + t.Errorf("_csrf = %q, want %q", got, simpleFeatureCookies.CSRF) + } + wantVerifyMenu := "" + if expectVerifyMenu { + wantVerifyMenu = "true" + } + if got := request.form.Get("verifyMenu"); got != wantVerifyMenu { + t.Errorf("verifyMenu = %q, want %q", got, wantVerifyMenu) + } +} + +func assertSimpleFeatureSnapshots(t *testing.T, got []helpers.TableSnapshot, want helpers.TableSnapshot) { + t.Helper() + + if len(got) != 1 { + t.Fatalf("table snapshot count = %d, want 1: %#v", len(got), got) + } + if !reflect.DeepEqual(got[0], want) { + t.Fatalf("table snapshot mismatch\ngot: %#v\nwant: %#v", got[0], want) + } +} + +func captureSimpleFeatureOutput(t *testing.T, invoke func()) (output string) { + t.Helper() + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create stdout pipe: %v", err) + } + previousStdout := os.Stdout + os.Stdout = writer + + done := make(chan string, 1) + go func() { + var captured bytes.Buffer + _, _ = io.Copy(&captured, reader) + done <- captured.String() + }() + + defer func() { + os.Stdout = previousStdout + _ = writer.Close() + output = <-done + _ = reader.Close() + }() + + invoke() + return output +} diff --git a/tests/killswitch_test.go b/tests/killswitch_test.go index 1af7211b..5d3b9434 100644 --- a/tests/killswitch_test.go +++ b/tests/killswitch_test.go @@ -1,204 +1,165 @@ package tests import ( - "bytes" + "cli-top/debug" "cli-top/helpers" - "cli-top/types" - "encoding/base64" - "encoding/json" - "image" - "image/jpeg" + "fmt" "net/http" "net/http/httptest" - "os" - "os/exec" - "path/filepath" + "sync/atomic" "testing" ) -type MockServer struct { - server *httptest.Server -} +const latestJSONURLEnv = "CLI_TOP_LATEST_JSON_URL" -func NewMockServer(killSwitch int) *MockServer { - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - versionInfo := types.VersionInfo{ - Version: "1.0.0", - KillSwitch: killSwitch, - } - json.NewEncoder(w).Encode(versionInfo) - }) +func newLatestJSONServer(t *testing.T, status int, body string) *httptest.Server { + t.Helper() - server := httptest.NewServer(handler) - return &MockServer{server: server} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + t.Errorf("expected GET request, got %s", request.Method) + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + return server } -func (m *MockServer) Close() { - m.server.Close() -} +func TestCheckKillSwitchStates(t *testing.T) { + for _, killSwitch := range []int{0, 1, 2, 3, 4} { + t.Run(fmt.Sprintf("state %d", killSwitch), func(t *testing.T) { + body := fmt.Sprintf(`{"version":"1.0.0","killSwitch":%d}`, killSwitch) + server := newLatestJSONServer(t, http.StatusOK, body) + t.Setenv(latestJSONURLEnv, server.URL) -func (m *MockServer) URL() string { - return m.server.URL + if got := helpers.CheckKillSwitch(); got != killSwitch { + t.Fatalf("CheckKillSwitch() = %d, want %d", got, killSwitch) + } + }) + } } -func getRealCaptcha() string { - img := image.NewRGBA(image.Rect(0, 0, 200, 40)) +func TestCheckKillSwitchFailsClosed(t *testing.T) { + tests := []struct { + name string + status int + body string + }{ + {name: "invalid JSON", status: http.StatusOK, body: `{"version":`}, + {name: "missing version", status: http.StatusOK, body: `{"killSwitch":0}`}, + {name: "missing kill switch", status: http.StatusOK, body: `{"version":"1.0.0"}`}, + {name: "invalid kill switch", status: http.StatusOK, body: `{"version":"1.0.0","killSwitch":5}`}, + {name: "server error", status: http.StatusServiceUnavailable, body: `{"version":"1.0.0","killSwitch":0}`}, + } - var buf bytes.Buffer - jpeg.Encode(&buf, img, nil) - base64Img := base64.StdEncoding.EncodeToString(buf.Bytes()) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := newLatestJSONServer(t, test.status, test.body) + t.Setenv(latestJSONURLEnv, server.URL) - return "data:image/jpeg;base64," + base64Img + if got := helpers.CheckKillSwitch(); got != 4 { + t.Fatalf("CheckKillSwitch() = %d, want facility view-only state 4", got) + } + if _, _, err := helpers.CheckUpdateSilently(); err == nil { + t.Fatal("CheckUpdateSilently() error = nil, want malformed response error") + } + }) + } } -func TestKillSwitchScenarios(t *testing.T) { - originalURL := helpers.GetLatestJSONURL() +func TestCheckKillSwitchFailsClosedWhenServerIsUnavailable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + serverURL := server.URL + server.Close() + t.Setenv(latestJSONURLEnv, serverURL) - testCases := []struct { - name string - killSwitch int - expected int - verifyFunc func(*testing.T, *MockServer) - }{ - { - name: "KillSwitch 0 - Allow automated captcha", - killSwitch: 0, - expected: 0, - verifyFunc: func(t *testing.T, mockServer *MockServer) { - captcha := getRealCaptcha() - if captcha == "" { - t.Skip("Could not get real captcha, skipping test") - return - } - - result := helpers.SolveCaptcha(captcha) - if result == "disabled" { - t.Error("Automated captcha solving should be enabled for killswitch 0") - } - os.Remove("captcha.jpg") - }, - }, - { - name: "KillSwitch 1 - Disable automated captcha", - killSwitch: 1, - expected: 1, - verifyFunc: func(t *testing.T, mockServer *MockServer) { - helpers.SetLatestJSONURL(mockServer.URL()) - helpers.CheckKillSwitch() - - captcha := getRealCaptcha() - if captcha == "" { - t.Skip("Could not get real captcha, skipping test") - return - } - - oldStdin := os.Stdin - defer func() { os.Stdin = oldStdin }() - - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - os.Stdin = r - - go func() { - defer w.Close() - w.Write([]byte("TEST123\n")) - }() - - result := helpers.SolveCaptcha(captcha) - if result != "TEST123" { - t.Error("Expected manual captcha input to be returned for killswitch 1") - } - if _, err := os.Stat("captcha.jpg"); os.IsNotExist(err) { - t.Error("captcha.jpg should be created for manual solving") - } - os.Remove("captcha.jpg") - }, - }, - { - name: "KillSwitch 2 - Disable app completely", - killSwitch: 2, - expected: 2, - verifyFunc: func(t *testing.T, mockServer *MockServer) { - tmpDir := t.TempDir() - tmpBin := filepath.Join(tmpDir, "cli-top-test.exe") - - if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test\ngo 1.21\n"), 0644); err != nil { - t.Fatal(err) - } - - testCode := ` - package main - import "fmt" - func main() { - helpers.Println("This version of cli-top has been decommissioned.") - } - ` - tmpGo := filepath.Join(tmpDir, "main.go") - if err := os.WriteFile(tmpGo, []byte(testCode), 0644); err != nil { - t.Fatal(err) - } - - cmd := exec.Command("go", "build", "-o", tmpBin, tmpGo) - cmd.Dir = tmpDir - if err := cmd.Run(); err != nil { - t.Fatal(err) - } - - output, err := exec.Command(tmpBin).CombinedOutput() - if err != nil { - t.Fatal(err) - } - - if !bytes.Contains(output, []byte("This version of cli-top has been decommissioned.")) { - t.Error("Expected decommissioned message for killswitch 2") - } - }, - }, - { - name: "KillSwitch 3 - Open VTOP in browser", - killSwitch: 3, - expected: 3, - verifyFunc: func(t *testing.T, mockServer *MockServer) { - url := "https://vtop.vit.ac.in" - err := helpers.OpenURLInBrowser(url) - if err != nil { - t.Errorf("Failed to open URL in browser: %v", err) - } - }, - }, + if got := helpers.CheckKillSwitch(); got != 4 { + t.Fatalf("CheckKillSwitch() = %d, want facility view-only state 4", got) } +} - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - mockServer := NewMockServer(tc.killSwitch) - defer mockServer.Close() +func TestCheckKillSwitchCachesSuccessUntilURLReset(t *testing.T) { + t.Setenv(latestJSONURLEnv, "") + originalURL := helpers.GetLatestJSONURL() + t.Cleanup(func() { helpers.SetLatestJSONURL(originalURL) }) + + var requests atomic.Int32 + var state atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + _, _ = fmt.Fprintf(w, `{"version":"1.0.0","killSwitch":%d}`, state.Load()) + })) + t.Cleanup(server.Close) + helpers.SetLatestJSONURL(server.URL) + + if got := helpers.CheckKillSwitch(); got != 0 { + t.Fatalf("first CheckKillSwitch() = %d, want 0", got) + } + state.Store(3) + if got := helpers.CheckKillSwitch(); got != 0 { + t.Fatalf("cached CheckKillSwitch() = %d, want 0", got) + } + if got := requests.Load(); got != 1 { + t.Fatalf("request count = %d, want 1 for cached read", got) + } - helpers.SetLatestJSONURL(mockServer.URL()) + helpers.SetLatestJSONURL(server.URL) + if got := helpers.CheckKillSwitch(); got != 3 { + t.Fatalf("CheckKillSwitch() after URL reset = %d, want 3", got) + } + if got := requests.Load(); got != 2 { + t.Fatalf("request count after URL reset = %d, want 2", got) + } +} - result := helpers.CheckKillSwitch() - if result != tc.expected { - t.Errorf("Expected killswitch value %d, got %d", tc.expected, result) - } +func TestLatestJSONEnvironmentOverride(t *testing.T) { + t.Setenv(latestJSONURLEnv, "") + originalURL := helpers.GetLatestJSONURL() + t.Cleanup(func() { helpers.SetLatestJSONURL(originalURL) }) - if tc.verifyFunc != nil { - tc.verifyFunc(t, mockServer) - } - }) + fallback := newLatestJSONServer(t, http.StatusOK, `{"version":"1.0.0","killSwitch":0}`) + override := newLatestJSONServer(t, http.StatusOK, `{"version":"1.0.0","killSwitch":3}`) + helpers.SetLatestJSONURL(fallback.URL) + if got := helpers.GetLatestJSONURL(); got != fallback.URL { + t.Fatalf("GetLatestJSONURL() = %q, want configured URL %q", got, fallback.URL) } + t.Setenv(latestJSONURLEnv, override.URL) - helpers.SetLatestJSONURL(originalURL) + if got := helpers.GetLatestJSONURL(); got != override.URL { + t.Fatalf("GetLatestJSONURL() = %q, want environment override %q", got, override.URL) + } + if got := helpers.CheckKillSwitch(); got != 3 { + t.Fatalf("CheckKillSwitch() = %d, want state from environment override 3", got) + } } -func TestBrowserOpening(t *testing.T) { - if os.Getenv("CI") != "" { - t.Skip("Skipping browser opening test in CI environment") +func TestCheckUpdateSilently(t *testing.T) { + tests := []struct { + name string + version string + available bool + }{ + {name: "current version", version: debug.Version, available: false}, + {name: "new version", version: debug.Version + "-new", available: true}, } - url := "https://vtop.vit.ac.in" - err := helpers.OpenURLInBrowser(url) - if err != nil { - t.Errorf("Failed to open URL in browser: %v", err) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"version":%q,"killSwitch":0}`, test.version) + })) + t.Cleanup(server.Close) + t.Setenv(latestJSONURLEnv, server.URL) + + available, version, err := helpers.CheckUpdateSilently() + if err != nil { + t.Fatalf("CheckUpdateSilently() error = %v", err) + } + if available != test.available || version != test.version { + t.Fatalf("CheckUpdateSilently() = (%v, %q), want (%v, %q)", available, version, test.available, test.version) + } + }) } } diff --git a/tests/proxy_command_test.go b/tests/proxy_command_test.go index 50f91d41..b481d3bd 100644 --- a/tests/proxy_command_test.go +++ b/tests/proxy_command_test.go @@ -14,16 +14,24 @@ func TestParseProxyFlagsCanonicalizesAliases(t *testing.T) { "--materials", "1,2-4", "--assignment", "cat1", "--facility", "badminton", + "--reason", "family visit", + "--details", "late lab", + "--building-id", "MB", + "--venue", "Main Gate", "--confirm", }) expected := map[string]string{ - "category": "score", - "course": "34", - "materials": "1,2-4", - "assignment": "cat1", - "facility": "badminton", - "confirm": "true", + "category": "score", + "course": "34", + "materials": "1,2-4", + "assignment": "cat1", + "facility": "badminton", + "reason": "family visit", + "details": "late lab", + "building-id": "MB", + "venue": "Main Gate", + "confirm": "true", } if !reflect.DeepEqual(flags, expected) { diff --git a/types/types.go b/types/types.go index 80e2e050..983f8f34 100644 --- a/types/types.go +++ b/types/types.go @@ -198,12 +198,6 @@ type VersionInfo struct { AndroidURL string `json:"androidUrl"` } -type TrackingData struct { - UUID string `json:"uuid"` - Command string `json:"command"` - Timestamp string `json:"timestamp"` -} - type VersionTrackingData struct { UUID string `json:"uuid"` Command string `json:"command"` @@ -215,11 +209,6 @@ type RegisterData struct { UUID string `json:"uuid"` } -type Kv struct { - Key int - Value float32 -} - type UploadResponse struct { URL string `json:"url"` }