628 lines
18 KiB
Markdown
628 lines
18 KiB
Markdown
# Phase 0: Server Behavior Validation Tests
|
|
|
|
## Overview
|
|
|
|
This document outlines the Phase 0 testing plan to validate Linkding server behavior regarding bookmark isolation, conflict resolution, and API authentication. These tests will inform the final architecture and conflict resolution strategy.
|
|
|
|
## Prerequisites
|
|
|
|
### Test Environment Setup
|
|
|
|
You need to create the following on your Linkding instance:
|
|
|
|
1. **Two User Accounts**:
|
|
- `user_work` - For work-related bookmarks
|
|
- `user_personal` - For personal bookmarks
|
|
|
|
2. **API Keys per User**:
|
|
- Each user needs at least 2 API keys (one for each bundle)
|
|
- Example: `user_work_key_1`, `user_work_key_2`, `user_personal_key_1`, `user_personal_key_2`
|
|
|
|
3. **Two Bundles per User**:
|
|
- `work` bundle (for work bookmarks)
|
|
- `personal` bundle (for personal bookmarks)
|
|
|
|
### Firefox Test Harness
|
|
|
|
We'll create a Firefox extension (or use existing Linkding extension) that:
|
|
- Allows testing with multiple API keys
|
|
- Can switch between work/personal contexts
|
|
- Runs tests via DevTools console or automated scripts
|
|
|
|
---
|
|
|
|
## Test Scenarios
|
|
|
|
### Scenario 1: Same URL, Different API Keys, Same User
|
|
|
|
**Purpose**: Verify if API keys provide bookmark isolation within the same user.
|
|
|
|
**Setup**:
|
|
1. Create bookmark via `user_work_key_1` with URL: `https://example.com`
|
|
2. Create same bookmark via `user_work_key_2` with same URL
|
|
|
|
**Expected Behavior**:
|
|
- If API keys only authenticate the user, both calls create/update the same bookmark
|
|
- API keys don't provide isolation - only user matters
|
|
|
|
**Test Command**:
|
|
```javascript
|
|
// Test 1: Same user, different API keys
|
|
async function testSameUserDifferentKeys(serverUrl, apiKey1, apiKey2) {
|
|
try {
|
|
// Create with first key
|
|
const response1 = await fetch(`${serverUrl}api/bookmarks/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Token ${apiKey1}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
url: 'https://example.com',
|
|
title: 'Test Bookmark 1',
|
|
description: 'First key',
|
|
notes: '{"path": "Work/Dev1", "userNotes": "Key 1"}'
|
|
})
|
|
});
|
|
const bookmark1 = await response1.json();
|
|
console.log('Bookmark 1:', bookmark1);
|
|
|
|
// Try creating same URL with second key
|
|
const response2 = await fetch(`${serverUrl}api/bookmarks/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Token ${apiKey2}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
url: 'https://example.com',
|
|
title: 'Test Bookmark 2',
|
|
description: 'Second key',
|
|
notes: '{"path": "Work/Dev2", "userNotes": "Key 2"}'
|
|
})
|
|
});
|
|
const bookmark2 = await response2.json();
|
|
console.log('Bookmark 2:', bookmark2);
|
|
|
|
// Check if they're the same bookmark (same ID)
|
|
if (bookmark1.id === bookmark2.id) {
|
|
console.log('RESULT: Same bookmark (API keys do not isolate)');
|
|
} else {
|
|
console.log('RESULT: Different bookmarks (API keys provide isolation)');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error.message);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Scenario 2: Same URL, Different Users
|
|
|
|
**Purpose**: Verify if different users can see each other's bookmarks without sharing enabled.
|
|
|
|
**Setup**:
|
|
1. User A creates bookmark via their API key
|
|
2. User B tries to retrieve same URL via their API key
|
|
|
|
**Expected Behavior**:
|
|
- Without sharing: User B should get 404 or empty results
|
|
- With sharing enabled: Both can see each other's bookmarks
|
|
|
|
**Test Command**:
|
|
```javascript
|
|
// Test 2: Different users, no sharing
|
|
async function testDifferentUsersNoSharing(serverUrl, userAKey, userBKey) {
|
|
try {
|
|
// Create bookmark as User A
|
|
const createResponse = await fetch(`${serverUrl}api/bookmarks/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Token ${userAKey}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
url: 'https://shared-example.com',
|
|
title: 'Shared Bookmark',
|
|
description: 'Created by User A',
|
|
notes: '{"path": "UserA/Shared"}'
|
|
})
|
|
});
|
|
const bookmark = await createResponse.json();
|
|
console.log('Bookmark created by User A:', bookmark.id);
|
|
|
|
// User B tries to get this bookmark
|
|
const getResponse = await fetch(`${serverUrl}api/bookmarks/?all=Shared`, {
|
|
headers: {
|
|
'Authorization': `Token ${userBKey}`,
|
|
}
|
|
});
|
|
|
|
if (getResponse.ok) {
|
|
const result = await getResponse.json();
|
|
console.log(`User B sees ${result.count} bookmarks`);
|
|
if (result.results && result.results.length > 0) {
|
|
console.log('RESULT: User B can see User A\'s bookmark (sharing or same user)');
|
|
} else {
|
|
console.log('RESULT: User B cannot see User A\'s bookmark (proper isolation)');
|
|
}
|
|
} else {
|
|
console.log('RESULT: User B cannot access (4xx/5xx error)');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error.message);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Scenario 3: Conflict Resolution - Different Folders/Paths
|
|
|
|
**Purpose**: Determine how the server handles same URL in different paths.
|
|
|
|
**Setup**:
|
|
1. Create bookmark as "Work" with path "Work/Development"
|
|
2. Create same URL as "Personal" with path "Personal/Notes"
|
|
|
|
**Expected Behavior**:
|
|
- Need to confirm if server creates duplicate or updates existing
|
|
|
|
**Test Command**:
|
|
```javascript
|
|
// Test 3: Conflict resolution with different paths
|
|
async function testConflictResolution(serverUrl, workKey, personalKey) {
|
|
const url = 'https://conflict-test.example.com';
|
|
const pathWork = 'Work/Development';
|
|
const pathPersonal = 'Personal/Notes';
|
|
|
|
try {
|
|
// Create as Work
|
|
const workBookmark = await createBookmark(serverUrl, workKey, url, {
|
|
title: 'Conflict Test',
|
|
description: 'Work version',
|
|
notes: JSON.stringify({
|
|
path: pathWork,
|
|
userNotes: 'Work Development Notes',
|
|
autoTags: [{name: 'Work'}]
|
|
})
|
|
});
|
|
console.log('Work bookmark created:', workBookmark.id);
|
|
|
|
// Create same URL as Personal
|
|
const personalBookmark = await createBookmark(serverUrl, personalKey, url, {
|
|
title: 'Conflict Test',
|
|
description: 'Personal version',
|
|
notes: JSON.stringify({
|
|
path: pathPersonal,
|
|
userNotes: 'Personal Notes',
|
|
autoTags: [{name: 'Personal'}]
|
|
})
|
|
});
|
|
console.log('Personal bookmark created:', personalBookmark.id);
|
|
|
|
// Check IDs
|
|
if (workBookmark.id === personalBookmark.id) {
|
|
console.log('RESULT: Same bookmark ID - paths are merged or overwritten');
|
|
// Fetch and show current state
|
|
const fetchResponse = await fetch(`${serverUrl}api/bookmarks/${workBookmark.id}/`, {
|
|
headers: {
|
|
'Authorization': `Token ${workKey}`,
|
|
}
|
|
});
|
|
const current = await fetchResponse.json();
|
|
console.log('Current bookmark state:', current);
|
|
} else {
|
|
console.log('RESULT: Different bookmark IDs - server creates duplicates');
|
|
console.log('Work ID:', workBookmark.id);
|
|
console.log('Personal ID:', personalBookmark.id);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error.message);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Scenario 4: Title/Description Conflict
|
|
|
|
**Purpose**: Understand conflict resolution for title/description fields.
|
|
|
|
**Setup**:
|
|
1. Update bookmark via Work context (title: "Work Title")
|
|
2. Update same bookmark via Personal context (title: "Personal Title")
|
|
3. Check final state
|
|
|
|
**Expected Behavior**:
|
|
- Confirm last-write-wins or other resolution strategy
|
|
|
|
**Test Command**:
|
|
```javascript
|
|
// Test 4: Title/Description conflict resolution
|
|
async function testTitleConflict(serverUrl, workKey, personalKey) {
|
|
const url = 'https://title-conflict.example.com';
|
|
|
|
try {
|
|
// Create initial bookmark
|
|
const bookmark = await createBookmark(serverUrl, workKey, url, {
|
|
title: 'Initial Title',
|
|
description: 'Initial Description',
|
|
notes: JSON.stringify({
|
|
path: 'Work/Initial',
|
|
userNotes: 'Initial'
|
|
})
|
|
});
|
|
|
|
// Update via Work
|
|
await updateBookmark(serverUrl, workKey, bookmark.id, {
|
|
title: 'Work Title',
|
|
description: 'Work Description',
|
|
notes: JSON.stringify({
|
|
path: 'Work/Dev',
|
|
userNotes: 'Work notes',
|
|
autoTags: [{name: 'Work'}]
|
|
})
|
|
});
|
|
console.log('Updated via Work: Work Title');
|
|
|
|
// Update via Personal
|
|
await updateBookmark(serverUrl, personalKey, bookmark.id, {
|
|
title: 'Personal Title',
|
|
description: 'Personal Description',
|
|
notes: JSON.stringify({
|
|
path: 'Personal/Notes',
|
|
userNotes: 'Personal notes',
|
|
autoTags: [{name: 'Personal'}]
|
|
})
|
|
});
|
|
console.log('Updated via Personal: Personal Title');
|
|
|
|
// Fetch final state
|
|
const final = await fetchBookmark(serverUrl, workKey, bookmark.id);
|
|
console.log('Final state:', {
|
|
title: final.title,
|
|
description: final.description,
|
|
notes: JSON.parse(final.notes)
|
|
});
|
|
|
|
if (final.title === 'Personal Title') {
|
|
console.log('RESULT: Last-write-wins (Personal update took precedence)');
|
|
} else if (final.title === 'Work Title') {
|
|
console.log('RESULT: Last-write-wins (Work update took precedence)');
|
|
} else if (final.title.includes('Work') && final.title.includes('Personal')) {
|
|
console.log('RESULT: Merged title');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error.message);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Scenario 5: Delete Propagation
|
|
|
|
**Purpose**: Confirm if deleting a bookmark affects all API keys.
|
|
|
|
**Setup**:
|
|
1. Create bookmark via multiple API keys (same URL)
|
|
2. Delete via one API key
|
|
3. Check if bookmark still exists via other keys
|
|
|
|
**Expected Behavior**:
|
|
- If API keys create same bookmark, deletion should propagate
|
|
- If API keys create separate bookmarks, deletion only affects one
|
|
|
|
**Test Command**:
|
|
```javascript
|
|
// Test 5: Delete propagation
|
|
async function testDeletePropagation(serverUrl, workKey, personalKey) {
|
|
const url = 'https://delete-test.example.com';
|
|
|
|
try {
|
|
// Create via both keys
|
|
const workBookmark = await createBookmark(serverUrl, workKey, url, {
|
|
title: 'Delete Test',
|
|
notes: JSON.stringify({path: 'Work/Dev'})
|
|
});
|
|
console.log('Work bookmark ID:', workBookmark.id);
|
|
|
|
const personalBookmark = await createBookmark(serverUrl, personalKey, url, {
|
|
title: 'Delete Test',
|
|
notes: JSON.stringify({path: 'Personal/Notes'})
|
|
});
|
|
console.log('Personal bookmark ID:', personalBookmark.id);
|
|
|
|
// Check if same bookmark
|
|
const sameBookmark = workBookmark.id === personalBookmark.id;
|
|
|
|
// Delete via Work key
|
|
const deleteResponse = await fetch(`${serverUrl}api/bookmarks/${workBookmark.id}/`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Token ${workKey}`,
|
|
}
|
|
});
|
|
|
|
console.log('Delete response status:', deleteResponse.status);
|
|
|
|
// Try to fetch via Personal key
|
|
const fetchPersonal = await fetch(`${serverUrl}api/bookmarks/?limit=100`, {
|
|
headers: {
|
|
'Authorization': `Token ${personalKey}`,
|
|
}
|
|
});
|
|
|
|
if (fetchPersonal.ok) {
|
|
const personalResults = await fetchPersonal.json();
|
|
const deleted = personalResults.results.find(b => b.url === url);
|
|
|
|
if (!deleted) {
|
|
console.log('RESULT: Delete propagated (same bookmark)');
|
|
} else {
|
|
console.log('RESULT: Delete did not propagate (separate bookmarks)');
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error.message);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Scenario 6: Bundle Tag Isolation
|
|
|
|
**Purpose**: Verify if different bundle tags properly isolate bookmarks.
|
|
|
|
**Setup**:
|
|
1. Create bookmark with bundle tag "work-bundle"
|
|
2. Create bookmark with bundle tag "personal-bundle"
|
|
3. Query each bundle separately
|
|
|
|
**Expected Behavior**:
|
|
- Bundle tags should act as filters
|
|
- Need to confirm if tags create separate bookmarks or filter existing ones
|
|
|
|
**Test Command**:
|
|
```javascript
|
|
// Test 6: Bundle tag isolation
|
|
async function testBundleIsolation(serverUrl, workKey, personalKey) {
|
|
try {
|
|
// Create bundle tag bookmark
|
|
const bookmark = await createBookmark(serverUrl, workKey, 'https://bundle-test.com', {
|
|
title: 'Bundle Test',
|
|
notes: JSON.stringify({
|
|
path: 'Test/Path',
|
|
userNotes: 'Initial',
|
|
bundleTag: 'work-bundle',
|
|
autoTags: [{name: 'work-bundle'}]
|
|
})
|
|
});
|
|
console.log('Bookmark created:', bookmark.id);
|
|
|
|
// Query by bundle tag
|
|
const workBundleResponse = await fetch(`${serverUrl}api/bookmarks/?all=work-bundle`, {
|
|
headers: {
|
|
'Authorization': `Token ${workKey}`,
|
|
}
|
|
});
|
|
|
|
const personalBundleResponse = await fetch(`${serverUrl}api/bookmarks/?all=personal-bundle`, {
|
|
headers: {
|
|
'Authorization': `Token ${workKey}`,
|
|
}
|
|
});
|
|
|
|
const workCount = await workBundleResponse.json();
|
|
const personalCount = await personalBundleResponse.json();
|
|
|
|
console.log(`Work bundle has ${workCount.count || workCount.results?.length || 0} bookmarks`);
|
|
console.log(`Personal bundle has ${personalCount.count || personalCount.results?.length || 0} bookmarks`);
|
|
|
|
if (workCount.count === 1 && personalCount.count === 0) {
|
|
console.log('RESULT: Bundle tags provide proper isolation');
|
|
} else {
|
|
console.log('RESULT: Bundle tags may not provide isolation');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error.message);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Session Management for Automated Tests
|
|
|
|
### Challenge: Authentication Context Switching
|
|
|
|
When running automated tests, we need to manage switching between:
|
|
- Different API keys
|
|
- Different users (if applicable)
|
|
- Different bundle contexts
|
|
|
|
### Solution: Context Switching Function
|
|
|
|
```javascript
|
|
// Session management for tests
|
|
const SessionManager = {
|
|
// Store active context
|
|
context: {
|
|
serverUrl: '',
|
|
currentApiKey: '',
|
|
currentUserId: null,
|
|
currentBundle: null
|
|
},
|
|
|
|
// Set new context
|
|
setContext(serverUrl, apiKey, userId = null, bundle = null) {
|
|
console.log(`Switching context: ${apiKey.substring(0, 8)}...`);
|
|
this.context.serverUrl = serverUrl;
|
|
this.context.currentApiKey = apiKey;
|
|
this.context.currentUserId = userId;
|
|
this.context.currentBundle = bundle;
|
|
|
|
// Return auth headers for this context
|
|
return {
|
|
headers: {
|
|
'Authorization': `Token ${apiKey}`
|
|
}
|
|
};
|
|
},
|
|
|
|
// Make API call with current context
|
|
async call(endpoint, method = 'GET', body = null) {
|
|
const headers = {
|
|
...this.getHeaders(),
|
|
'Content-Type': 'application/json'
|
|
};
|
|
|
|
if (body) headers['Content-Type'] = 'application/json';
|
|
|
|
const response = await fetch(`${this.context.serverUrl}${endpoint}`, {
|
|
method,
|
|
headers,
|
|
body: body ? JSON.stringify(body) : null
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
return await response.json();
|
|
},
|
|
|
|
// Get headers for current context
|
|
getHeaders() {
|
|
return {
|
|
'Authorization': `Token ${this.context.currentApiKey}`
|
|
};
|
|
},
|
|
|
|
// Reset to default context
|
|
reset() {
|
|
this.context = {
|
|
serverUrl: '',
|
|
currentApiKey: '',
|
|
currentUserId: null,
|
|
currentBundle: null
|
|
};
|
|
console.log('Session reset');
|
|
}
|
|
};
|
|
|
|
// Usage example:
|
|
// SessionManager.setContext('https://links.blabber1565.com', 'work-api-key-123');
|
|
// const bookmark = await SessionManager.call('/api/bookmarks/', 'POST', {url: '...'});
|
|
```
|
|
|
|
---
|
|
|
|
## Test Execution Script
|
|
|
|
Create a file: `test-runner.js` in the LinkdingSync directory:
|
|
|
|
```javascript
|
|
/*
|
|
* Test Runner for Linkding Server Behavior Validation
|
|
*
|
|
* Instructions:
|
|
* 1. Load this file into Firefox DevTools console
|
|
* 2. Enter your test configuration
|
|
* 3. Run test suite
|
|
*/
|
|
|
|
// Configuration
|
|
const CONFIG = {
|
|
serverUrl: 'https://links.blabber1565.com',
|
|
workApiKey: '', // Fill in your work API key
|
|
personalApiKey: '', // Fill in your personal API key
|
|
workBundle: 'work-bundle',
|
|
personalBundle: 'personal-bundle'
|
|
};
|
|
|
|
// Import SessionManager
|
|
// (paste SessionManager code from above)
|
|
|
|
// Test Suite
|
|
async function runTestSuite() {
|
|
console.log('=== Linkding Server Behavior Test Suite ===\n');
|
|
|
|
try {
|
|
// Setup context
|
|
SessionManager.setContext(CONFIG.serverUrl, CONFIG.workApiKey, 'user_work', CONFIG.workBundle);
|
|
|
|
// Run tests
|
|
await testSameUserDifferentKeys(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
|
console.log('\n--- Test 1 Complete ---\n');
|
|
|
|
await testDifferentUsersNoSharing(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
|
console.log('\n--- Test 2 Complete ---\n');
|
|
|
|
await testConflictResolution(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
|
console.log('\n--- Test 3 Complete ---\n');
|
|
|
|
await testTitleConflict(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
|
console.log('\n--- Test 4 Complete ---\n');
|
|
|
|
await testDeletePropagation(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
|
console.log('\n--- Test 5 Complete ---\n');
|
|
|
|
await testBundleIsolation(CONFIG.serverUrl, CONFIG.workApiKey, CONFIG.personalApiKey);
|
|
console.log('\n--- Test 6 Complete ---\n');
|
|
|
|
console.log('=== All Tests Complete ===');
|
|
|
|
} catch (error) {
|
|
console.error('Test suite failed:', error.message);
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
console.log('Type: runTestSuite() to execute all tests');
|
|
console.log('Type: runTestSuite("scenario") to run specific scenario');
|
|
```
|
|
|
|
---
|
|
|
|
## Expected Outcomes & Decision Points
|
|
|
|
After running Phase 0 tests, we'll make decisions based on:
|
|
|
|
### Decision Point 1: API Key Isolation
|
|
- **If same user**: API keys don't provide isolation → Use separate users or different accounts
|
|
- **If different users**: Confirm sharing settings needed
|
|
|
|
### Decision Point 2: Conflict Resolution Strategy
|
|
- **If last-write-wins**: May cause toggle issues between browsers
|
|
- **If merge**: Determine merge strategy (path, title, etc.)
|
|
- **If duplicates**: Accept separate bookmarks per context
|
|
|
|
### Decision Point 3: Bundle Isolation
|
|
- **If tags isolate**: Use bundle tags for isolation
|
|
- **If not**: Need user accounts for isolation
|
|
|
|
---
|
|
|
|
## Next Steps
|
|
|
|
1. **Create Test Accounts**: You create the 2 user accounts with API keys
|
|
2. **Configure Test Harness**: Fill in CONFIG with API keys
|
|
3. **Run Tests**: Execute `runTestSuite()` in Firefox console
|
|
4. **Analyze Results**: Review test outputs
|
|
5. **Finalize Design**: Update architecture based on findings
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
- Tests are idempotent - they clean up test bookmarks after execution
|
|
- All test bookmarks are tagged with "test-" prefix for easy identification
|
|
- Test results will inform the final redesign of LinkdingSync extension |