Files
bookhoard/cmd/server/tests/device_test.go
T
john-okeefe 527c406f75 test: add comprehensive device token regeneration tests
- Test successful token regeneration
- Verify old tokens are invalidated after regeneration
- Test unauthorized and forbidden access scenarios
- Test not found and device type-specific behavior
- Validate sync URLs contain new tokens
2026-02-13 16:37:58 -05:00

529 lines
20 KiB
Go

package main
import (
"bookhoard/internal/database"
"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"
)
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(), &regResponse)
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 map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
devices, ok := response["devices"].([]interface{})
assert.True(t, ok, "Should have devices array")
assert.GreaterOrEqual(t, len(devices), 1, "Should have at least one device")
firstDevice := devices[0].(map[string]interface{})
deviceName, ok := firstDevice["device_name"].(string)
assert.True(t, ok, "Should have device_name")
assert.Equal(t, "Test Device", 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
updateRequest := map[string]interface{}{
"device_name": "Updated Device Name",
"sync_enabled": false,
"sync_frequency_minutes": int32(10),
}
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")
updatedDevice := response["device"].(map[string]interface{})
assert.Equal(t, "Updated Device Name", updatedDevice["device_name"], "Should have updated name")
assert.Equal(t, false, updatedDevice["sync_enabled"], "Should be disabled")
assert.Equal(t, float64(10), updatedDevice["sync_frequency_minutes"], "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)
token := loginTestUser(t, setup.Server, setup.DB)
req := httptest.NewRequest("GET", "/api/devices/pending", nil)
req.Header.Set("Authorization", "Bearer "+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)
token := loginTestUser(t, setup.Server, setup.DB)
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(), &regResponse)
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 "+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)
token := loginTestUser(t, setup.Server, setup.DB)
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(), &regResponse)
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 "+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)
// 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")
}