App reinstalls that preserve data (Android Studio installDebug over an existing install) re-register with the same device_identifier, but the devices row from the previous install still exists — device_identifier is UNIQUE, so ApproveDevice's blind INSERT failed with a unique violation and returned 500 'failed to create device' (reproduced via curl: second approve with the same identifier = instant 500; the ~98s in the original report was app-side retry/polling, not server wait). ApproveDevice is now idempotent: look the device up by identifier first; a row owned by the approving user gets its auth token rotated via UpdateDeviceAuthToken (row id unchanged, so synced highlights/ bookmarks/progress anchored to it stay valid; fresh install = fresh credentials, old token invalidated); a row owned by another user gets 409; unknown identifiers INSERT as before, with the 23505 race falling through to the rotate path. DB failures are logged (they were silent). Also guard the in-memory pendingRegistrations map with a mutex — register/approve/reject/status/list all touch it from HTTP goroutines, and a racing write is a Go runtime fatal, not an error. The approver's credential publication and the status poller's approved-branch snapshot now run under the lock so the token can never be read half-written. Regression test: TestApproveDeviceReapprovalRotatesToken — register → approve → re-register same identifier → approve (must be 200) → token rotated, exactly one devices row, row carries the new token.
620 lines
23 KiB
Go
620 lines
23 KiB
Go
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@tests.bookhoard.internal",
|
|
"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")
|
|
}
|
|
|
|
// Regression test for the "approve returns 500 after app reinstall" bug:
|
|
// an app reinstall that preserves data (e.g. Android Studio installDebug
|
|
// over an existing install) re-registers with the SAME device_identifier,
|
|
// and the blind INSERT in ApproveDevice hit the UNIQUE(device_identifier)
|
|
// constraint. Re-approval must be idempotent: same row (id unchanged),
|
|
// rotated token, old token invalidated.
|
|
func TestApproveDeviceReapprovalRotatesToken(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
userID := getTestUserID(t, setup.DB)
|
|
|
|
identifier := fmt.Sprintf("repro-reinstall-%s", uuid.New().String())
|
|
|
|
registerAndApprove := func() string {
|
|
regRequest := map[string]interface{}{
|
|
"device_name": "Reinstall Device",
|
|
"device_type": "mobile",
|
|
"device_identifier": identifier,
|
|
}
|
|
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)
|
|
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate registration")
|
|
|
|
var regResponse map[string]interface{}
|
|
json.Unmarshal(rec.Body.Bytes(), ®Response)
|
|
registrationID, ok := regResponse["registration_id"].(string)
|
|
require.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)
|
|
require.Equal(t, http.StatusOK, rec.Code,
|
|
"Approve must succeed even when the identifier already exists (was the reinstall 500)")
|
|
|
|
// The status response is single-use and carries the credentials.
|
|
statusBody, _ := json.Marshal(map[string]string{"registration_id": registrationID})
|
|
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)
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
var statusResponse map[string]interface{}
|
|
json.Unmarshal(rec.Body.Bytes(), &statusResponse)
|
|
require.Equal(t, "approved", statusResponse["status"])
|
|
token, ok := statusResponse["auth_token"].(string)
|
|
require.True(t, ok, "Should have auth_token")
|
|
require.NotEmpty(t, token)
|
|
return token
|
|
}
|
|
|
|
token1 := registerAndApprove()
|
|
token2 := registerAndApprove()
|
|
|
|
assert.NotEqual(t, token1, token2, "Re-approval must rotate the auth token (fresh install = fresh credentials)")
|
|
|
|
devices, err := setup.DB.ListDevicesByUser(context.Background(),
|
|
pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
|
require.NoError(t, err)
|
|
rows := 0
|
|
for _, d := range devices {
|
|
if d.DeviceIdentifier == identifier {
|
|
rows++
|
|
}
|
|
}
|
|
assert.Equal(t, 1, rows, "Re-approval must reuse the existing devices row, not duplicate it")
|
|
|
|
// The rotated-in token must be the live one.
|
|
var live *database.Devices
|
|
for i := range devices {
|
|
if devices[i].DeviceIdentifier == identifier {
|
|
live = &devices[i]
|
|
}
|
|
}
|
|
require.NotNil(t, live)
|
|
assert.Equal(t, token2, live.AuthToken, "The devices row must carry the newly rotated token")
|
|
}
|
|
|
|
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")
|
|
}
|