Files
bookhoard/cmd/server/tests/device_test.go
T
John O'Keefe 7d88ec5184 fix(tests): test-created devices and fixture dirs now meet the cleanup contract
Test suites that attach devices to the SHARED admin (testuser, the live
dev account) escaped the harness sweeps whenever their names lacked
"test": createContractDeviceNamed named its devices by surface type
("koreader" — the web progress push adopts the device name as sync
source, so conflict tests drive a second named source without a
separate account), the reinstall regression test uses the web flow's
real "Reinstall Device" label, and opds_test.go's folders test named
its device "OPDS Folders Device". None had self-cleanup either, so
every suite run left 3 more rows on the dev account's device grid —
476 by the time anyone looked. TestDeviceSetup.CreateDevice also never
cleanup-registered, so any CURRENT or future helper-created device was
one rename away from the same leak.

Changes:
- TestDeviceSetup.CreateDevice self-deletes its row at test end
  (t.Cleanup): callers owe nothing to name hygiene any more.
- createContractDeviceNamed self-deletes likewise (contract-% prefix).
- device_test.go: core via cleanupDeviceByIdentifier on every device it
  attaches to the admin ("Reinstall Device" by unique identifier —
  the NAME is user-facing-labeled, sweeps must never torch it).
- Harness setup-time admin-device sweep gains the unambiguous harness
  patterns (dev_contract_* tokens, contract-%/opds-%/repro-reinstall-%
  identifiers) as the drift net; ran once with the rebuilt tests image
  and that sweep alone already purged the existing 476 rows.
- metadata_write_test.go now globs ALL test-metadata-write-* dirs in
  its RemoveAll instead of only its own pid's — killed runs left root-
  owned dirs on the user's uploads bind mount (7 found, deleted).

 audits: collections/saved-filters/libraries/sync_queue are clean;
 the only "test"-named media rows left are scans of the user's own
 on-disk test files (Test/, Bookhoard Test/, embedded " test" in two
 real books' metadata) — content, not suite debris.

Verified: filtered runs (contract/OPDS/reinstall/pairing/metadata)
green, zero device residue after the run, uploads mount clean.
2026-11-02 17:39:29 -05:00

651 lines
25 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)
// Approved through the shared admin's session; the row must not
// outlive the test (name hygiene alone should never carry it).
cleanupDeviceByIdentifier(t, setup, "kindle-test-hw-id-12345")
// 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@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)
cleanupDeviceByIdentifier(t, setup, "test-device-123")
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)
// A live registration must NOT appear in any account's pending list:
// the broadcast is what let any user claim (or reject) someone else's
// device. Pairing codes replaced it — see device_pairing_test.go.
regRequest := map[string]interface{}{
"device_name": "Broadcast Test Device",
"device_type": "koreader",
"device_identifier": fmt.Sprintf("broadcast-test-%s", uuid.New().String()),
}
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")
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{})
require.True(t, ok, "Should have pending_registrations array")
assert.Empty(t, pending, "Unclaimed registrations must be invisible to every account")
assert.Equal(t, float64(0), response["total"], "Total must stay zero while a registration is live")
}
// 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())
// Self-delete: the row is named "Reinstall Device" — the web
// reinstall flow's own label — so the name-based sweeps can never
// safely touch it. The identifier is this test's fixture alone.
cleanupDeviceByIdentifier(t, setup, identifier)
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(), &regResponse)
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)
// Approved through the shared admin's session; the row must not
// outlive the test (name hygiene alone should never carry it).
cleanupDeviceByIdentifier(t, setup, "test-approval-12345")
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 "+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")
}
// TestRejectDeviceRegistrationRemoved pins the removal: the reject route is
// gone because unclaimed registrations are invisible since pairing codes
// took over — there is nothing left for anyone to reject. See
// device_pairing_test.go for the replacement flow (cancel by the
// registering device itself).
func TestRejectDeviceRegistrationRemoved(t *testing.T) {
setup := setupTestServer(t)
regRequest := map[string]interface{}{
"device_name": "Test Device for Rejection",
"device_type": "koreader",
"device_identifier": fmt.Sprintf("test-rejection-%s", uuid.New().String()),
}
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 device registration")
var regResponse map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &regResponse)
registrationID, ok := regResponse["registration_id"].(string)
require.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)
// 404 = no route, 405 = path-shaped collision without POST — either
// proves the reject surface is gone.
assert.True(t, rec.Code == http.StatusNotFound || rec.Code == http.StatusMethodNotAllowed,
"Reject route must not exist (registration invisible, nothing to reject); got %d", rec.Code)
}
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-test-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",
Timezone: pgtype.Text{String: "UTC", Valid: true},
})
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")
}