18 KiB
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:
-
Two User Accounts:
user_work- For work-related bookmarksuser_personal- For personal bookmarks
-
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
-
Two Bundles per User:
workbundle (for work bookmarks)personalbundle (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:
- Create bookmark via
user_work_key_1with URL:https://example.com - Create same bookmark via
user_work_key_2with 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:
// 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:
- User A creates bookmark via their API key
- 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:
// 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:
- Create bookmark as "Work" with path "Work/Development"
- Create same URL as "Personal" with path "Personal/Notes"
Expected Behavior:
- Need to confirm if server creates duplicate or updates existing
Test Command:
// 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:
- Update bookmark via Work context (title: "Work Title")
- Update same bookmark via Personal context (title: "Personal Title")
- Check final state
Expected Behavior:
- Confirm last-write-wins or other resolution strategy
Test Command:
// 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:
- Create bookmark via multiple API keys (same URL)
- Delete via one API key
- 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:
// 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:
- Create bookmark with bundle tag "work-bundle"
- Create bookmark with bundle tag "personal-bundle"
- 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:
// 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
// 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:
/*
* 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
- Create Test Accounts: You create the 2 user accounts with API keys
- Configure Test Harness: Fill in CONFIG with API keys
- Run Tests: Execute
runTestSuite()in Firefox console - Analyze Results: Review test outputs
- 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