package main import ( "bookhoard/internal/database" "bookhoard/internal/handlers" "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDeviceRegistrationFlow(t *testing.T) { setup := setupTestServer(t) // Step 1: Initiate device registration regRequest := map[string]interface{}{ "device_name": "Test Kindle Paperwhite", "device_type": "koreader", "device_identifier": "kindle-test-hw-id-12345", } regBody, _ := json.Marshal(regRequest) req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration") var regResponse map[string]interface{} json.Unmarshal(rec.Body.Bytes(), ®Response) registrationID, ok := regResponse["registration_id"].(string) assert.True(t, ok, "Should have registration_id") assert.NotEmpty(t, registrationID, "Registration ID should not be empty") authURL, ok := regResponse["auth_url"].(string) assert.True(t, ok, "Should have auth_url") assert.NotEmpty(t, authURL, "Auth URL should not be empty") // Step 2: Check registration status (should be pending initially) statusRequest := map[string]interface{}{ "registration_id": registrationID, } statusBody, _ := json.Marshal(statusRequest) req = httptest.NewRequest("POST", "/api/devices/register/status", bytes.NewReader(statusBody)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Should check registration status") var statusResponse map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &statusResponse) status, ok := statusResponse["status"].(string) assert.True(t, ok, "Should have status") assert.Equal(t, "pending", status, "Should be pending initially") // Step 3: Login as user to approve device loginRequest := map[string]interface{}{ "login": "testuser@example.com", "password": "Test@Pass123!", } loginBody, _ := json.Marshal(loginRequest) req = httptest.NewRequest("POST", "/api/auth/login", bytes.NewReader(loginBody)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Should login successfully") var loginResponse map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &loginResponse) token, ok := loginResponse["access_token"].(string) assert.True(t, ok, "Should have access_token") // Step 4: Approve the device req = httptest.NewRequest("GET", fmt.Sprintf("/api/devices/approve/%s", registrationID), nil) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Should approve device") // Step 5: Check registration status again (should be approved now) req = httptest.NewRequest("POST", "/api/devices/register/status", bytes.NewReader(statusBody)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Should check registration status after approval") var approvedStatus map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &approvedStatus) status, ok = approvedStatus["status"].(string) assert.True(t, ok, "Should have status") assert.Equal(t, "approved", status, "Should be approved after user approval") authToken, ok := approvedStatus["auth_token"].(string) assert.True(t, ok, "Should have auth_token after approval") assert.NotEmpty(t, authToken, "Auth token should not be empty") } func TestListDevices(t *testing.T) { setup := setupDeviceTest(t) defer setup.Server.Close() // Create a device using the setup helper _ = setup.CreateDevice(t, "Test Device", "koreader", "test-device-123") // List devices req := httptest.NewRequest("GET", "/api/devices", nil) req.Header.Set("Authorization", "Bearer "+setup.UserToken) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Should list devices") var response handlers.DeviceListResponse err := json.Unmarshal(rec.Body.Bytes(), &response) require.NoError(t, err, "Should unmarshal device list response") assert.GreaterOrEqual(t, len(response.Devices), 1, "Should have at least one device") firstDevice := response.Devices[0] assert.Equal(t, "Test Device", firstDevice.DeviceName, "Should match created device name") } func TestUpdateDevice(t *testing.T) { setup := setupDeviceTest(t) defer setup.Server.Close() // Create a device using the setup helper device := setup.CreateDevice(t, "Test Device", "koreader", "test-device-123") // Update device syncEnabled := false syncFreq := int32(10) updateRequest := handlers.DeviceUpdateRequest{ DeviceName: "Updated Device Name", SyncEnabled: &syncEnabled, SyncFrequencyMinutes: &syncFreq, } updateBody, _ := json.Marshal(updateRequest) req := httptest.NewRequest("PUT", fmt.Sprintf("/api/devices/%s", device.ID.String()), bytes.NewReader(updateBody)) req.Header.Set("Authorization", "Bearer "+setup.UserToken) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Should update device") var response map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &response) assert.True(t, response["device_updated"].(bool), "Should confirm device updated") // NEW: Verify database state updatedDevice := response["device"].(map[string]interface{}) assert.Equal(t, "Updated Device Name", updatedDevice["device_name"], "Should have updated name") // Verify in database pgDeviceID := pgtype.UUID{Bytes: [16]byte(device.ID), Valid: true} dbDevice, err := setup.DB.GetDevice(context.Background(), pgDeviceID) require.NoError(t, err, "Should retrieve updated device") assert.Equal(t, "Updated Device Name", dbDevice.DeviceName, "DB should have updated name") assert.Equal(t, false, dbDevice.SyncEnabled.Bool, "DB should show sync disabled") assert.Equal(t, int32(10), dbDevice.SyncFrequencyMinutes.Int32, "DB should have updated frequency") } func TestDeleteDevice(t *testing.T) { setup := setupDeviceTest(t) defer setup.Server.Close() // Create a device using the setup helper device := setup.CreateDevice(t, "Test Device", "koreader", "test-device-123") // Delete device req := httptest.NewRequest("DELETE", fmt.Sprintf("/api/devices/%s", device.ID.String()), nil) req.Header.Set("Authorization", "Bearer "+setup.UserToken) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusNoContent, rec.Code, "Should delete device") // Verify device is deleted pgDeviceID := pgtype.UUID{Bytes: [16]byte(device.ID), Valid: true} _, err := setup.DB.GetDevice(context.Background(), pgDeviceID) assert.Error(t, err, "Device should be deleted") } func TestDeviceAuthentication(t *testing.T) { setup := setupTestServer(t) // Create a device directly in the database userID := getTestUserID(t, setup.DB) deviceToken := fmt.Sprintf("dev_%s", uuid.New().String()) _, err := setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{ UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, DeviceName: "Test Device", DeviceType: "koreader", DeviceIdentifier: "test-device-123", AuthToken: deviceToken, SyncEnabled: pgtype.Bool{Bool: true, Valid: true}, AutoSync: pgtype.Bool{Bool: true, Valid: true}, SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true}, DeviceMetadata: []byte("{}"), }) assert.NoError(t, err, "Should create device") // Test device authentication req := httptest.NewRequest("GET", "/api/devices", nil) req.Header.Set("Authorization", "Bearer "+deviceToken) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) // This should fail because device auth middleware is not applied to /api/devices // Device auth is for sync endpoints only assert.Equal(t, http.StatusUnauthorized, rec.Code, "Should require user auth for device management") } func TestListPendingRegistrations(t *testing.T) { setup := setupTestServer(t) req := httptest.NewRequest("GET", "/api/devices/pending", nil) req.Header.Set("Authorization", "Bearer "+setup.Token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Should list pending registrations") var response map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &response) pending, ok := response["pending_registrations"].([]interface{}) assert.True(t, ok, "Should have pending_registrations array") assert.NotNil(t, pending, "Pending registrations should not be nil") } func TestApproveDeviceRegistration(t *testing.T) { setup := setupTestServer(t) regRequest := map[string]interface{}{ "device_name": "Test Device for Approval", "device_type": "koreader", "device_identifier": "test-approval-12345", } regBody, _ := json.Marshal(regRequest) req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration") var regResponse map[string]interface{} json.Unmarshal(rec.Body.Bytes(), ®Response) registrationID, ok := regResponse["registration_id"].(string) assert.True(t, ok, "Should have registration_id") req = httptest.NewRequest("GET", fmt.Sprintf("/api/devices/approve/%s", registrationID), nil) req.Header.Set("Authorization", "Bearer "+setup.Token) rec = httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Should approve device registration") var approveResponse map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &approveResponse) assert.True(t, approveResponse["approved"].(bool), "Should confirm approval") } func TestRejectDeviceRegistration(t *testing.T) { setup := setupTestServer(t) regRequest := map[string]interface{}{ "device_name": "Test Device for Rejection", "device_type": "koreader", "device_identifier": "test-rejection-12345", } regBody, _ := json.Marshal(regRequest) req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusCreated, rec.Code, "Should initiate device registration") var regResponse map[string]interface{} json.Unmarshal(rec.Body.Bytes(), ®Response) registrationID, ok := regResponse["registration_id"].(string) assert.True(t, ok, "Should have registration_id") req = httptest.NewRequest("POST", fmt.Sprintf("/api/devices/reject/%s", registrationID), nil) req.Header.Set("Authorization", "Bearer "+setup.Token) rec = httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Should reject device registration") var rejectResponse map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &rejectResponse) assert.Equal(t, "device registration rejected", rejectResponse["message"], "Should confirm rejection message") } func TestRegenerateDeviceToken_Success(t *testing.T) { setup := setupDeviceTest(t) defer setup.Server.Close() // Create a device device := setup.CreateDevice(t, "Test Device", "koreader", "test-device-123") oldToken := device.AuthToken // Regenerate token req := httptest.NewRequest("PUT", fmt.Sprintf("/api/devices/%s/regenerate-token", device.ID.String()), nil) req.Header.Set("Authorization", "Bearer "+setup.UserToken) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Should regenerate token") var response map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &response) assert.True(t, response["message"].(string) != "", "Should have success message") newToken, ok := response["auth_token"].(string) assert.True(t, ok, "Should have auth_token") assert.NotEmpty(t, newToken, "New token should not be empty") assert.NotEqual(t, oldToken, newToken, "New token should be different from old token") // Verify device info is returned deviceInfo, ok := response["device"].(map[string]interface{}) assert.True(t, ok, "Should have device info") assert.Equal(t, "Test Device", deviceInfo["device_name"], "Should return device name") // Verify sync URLs are returned syncURLs, ok := response["sync_urls"].(map[string]interface{}) assert.True(t, ok, "Should have sync_urls") assert.Contains(t, syncURLs, "progress", "Should have progress URL") assert.Contains(t, syncURLs, "metadata", "Should have metadata URL") } func TestRegenerateDeviceToken_OldTokenInvalidated(t *testing.T) { setup := setupDeviceTest(t) defer setup.Server.Close() // Create a device device := setup.CreateDevice(t, "Test Device", "koreader", "test-device-invalidated") // Test old token works initially req1 := httptest.NewRequest("POST", "/api/sync/koreader/progress", nil) req1.Header.Set("Authorization", "Bearer "+device.AuthToken) req1.Header.Set("Content-Type", "application/json") rec1 := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec1, req1) // May fail for other reasons (no data), but should not be unauthorized assert.NotEqual(t, http.StatusUnauthorized, rec1.Code, "Old token should work initially") // Regenerate token req2 := httptest.NewRequest("PUT", fmt.Sprintf("/api/devices/%s/regenerate-token", device.ID.String()), nil) req2.Header.Set("Authorization", "Bearer "+setup.UserToken) rec2 := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec2, req2) assert.Equal(t, http.StatusOK, rec2.Code, "Should regenerate token") var response map[string]interface{} json.Unmarshal(rec2.Body.Bytes(), &response) newToken := response["auth_token"].(string) // Test old token no longer works req3 := httptest.NewRequest("POST", "/api/sync/koreader/progress", nil) req3.Header.Set("Authorization", "Bearer "+device.AuthToken) req3.Header.Set("Content-Type", "application/json") rec3 := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec3, req3) assert.Equal(t, http.StatusUnauthorized, rec3.Code, "Old token should be invalid after regeneration") // Test new token works req4 := httptest.NewRequest("POST", "/api/sync/koreader/progress", nil) req4.Header.Set("Authorization", "Bearer "+newToken) req4.Header.Set("Content-Type", "application/json") rec4 := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec4, req4) // May fail for other reasons, but should not be unauthorized assert.NotEqual(t, http.StatusUnauthorized, rec4.Code, "New token should work") } func TestRegenerateDeviceToken_Unauthorized(t *testing.T) { setup := setupDeviceTest(t) defer setup.Server.Close() device := setup.CreateDevice(t, "Test Device", "koreader", "test-device-unauth") // Try to regenerate without JWT token req := httptest.NewRequest("PUT", fmt.Sprintf("/api/devices/%s/regenerate-token", device.ID.String()), nil) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusUnauthorized, rec.Code, "Should require authentication") var response map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &response) assert.Contains(t, response, "error", "Should return error message") } func TestRegenerateDeviceToken_Forbidden(t *testing.T) { setup := setupDeviceTest(t) defer setup.Server.Close() // Create device for user 1 device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-device") // Create a second user with different credentials ctx := context.Background() passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq" _, err := setup.DB.CreateUser(ctx, database.CreateUserParams{ Email: "differentuser@example.com", Username: "differentuser", PasswordHash: passwordHash, FirstName: pgtype.Text{String: "Different", Valid: true}, LastName: pgtype.Text{String: "User", Valid: true}, Role: "user", }) assert.NoError(t, err) // Clean up the second user after the test defer func() { user2, err := setup.DB.GetUserByEmail(ctx, "differentuser@example.com") if err == nil { setup.DB.DeleteUser(ctx, user2.ID) } }() // Login as user 2 loginRequest := map[string]interface{}{ "login": "differentuser@example.com", "password": "Test@Pass123!", } loginBody, _ := json.Marshal(loginRequest) loginReq := httptest.NewRequest("POST", "/api/auth/login", bytes.NewReader(loginBody)) loginReq.Header.Set("Content-Type", "application/json") loginRec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(loginRec, loginReq) var loginResponse map[string]interface{} json.Unmarshal(loginRec.Body.Bytes(), &loginResponse) user2Token := loginResponse["access_token"].(string) // Try to regenerate user 1's device with user 2's token req := httptest.NewRequest("PUT", fmt.Sprintf("/api/devices/%s/regenerate-token", device1.ID.String()), nil) req.Header.Set("Authorization", "Bearer "+user2Token) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusForbidden, rec.Code, "Should forbid access to other user's device") var response map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &response) assert.Contains(t, response, "error", "Should return error message") } func TestRegenerateDeviceToken_NotFound(t *testing.T) { setup := setupDeviceTest(t) defer setup.Server.Close() fakeDeviceID, _ := uuid.NewUUID() // Try to regenerate non-existent device req := httptest.NewRequest("PUT", fmt.Sprintf("/api/devices/%s/regenerate-token", fakeDeviceID.String()), nil) req.Header.Set("Authorization", "Bearer "+setup.UserToken) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusNotFound, rec.Code, "Should return not found") var response map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &response) assert.Contains(t, response, "error", "Should return error message") } func TestRegenerateDeviceToken_KoboDevice(t *testing.T) { setup := setupDeviceTest(t) defer setup.Server.Close() // Create a Kobo device koboDevice := setup.CreateDevice(t, "Test Kobo", "kobo", "test-kobo-regen") oldToken := koboDevice.AuthToken // Regenerate token req := httptest.NewRequest("PUT", fmt.Sprintf("/api/devices/%s/regenerate-token", koboDevice.ID.String()), nil) req.Header.Set("Authorization", "Bearer "+setup.UserToken) rec := httptest.NewRecorder() setup.Server.Config.Handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code, "Should regenerate token") var response map[string]interface{} json.Unmarshal(rec.Body.Bytes(), &response) newToken := response["auth_token"].(string) assert.NotEqual(t, oldToken, newToken, "New token should be different") // Verify Kobo sync URLs are returned syncURLs, ok := response["sync_urls"].(map[string]interface{}) assert.True(t, ok, "Should have sync_urls") assert.Contains(t, syncURLs, "sync_url", "Should have sync_url") assert.Contains(t, syncURLs, "markup", "Should have markup URL") assert.Contains(t, syncURLs, "bookmark", "Should have bookmark URL") assert.Contains(t, syncURLs, "init", "Should have init URL") // Verify URLs contain new token syncURL := syncURLs["sync_url"].(string) assert.Contains(t, syncURL, newToken, "Sync URL should contain new token") assert.NotContains(t, syncURL, oldToken, "Sync URL should not contain old token") }