fix(api): honor the request's origin as the writer label — app rows stop wearing "web"
Release / build-and-push (push) Successful in 3m36s
Release / build-and-push (push) Successful in 3m36s
Every web-API write endpoint hardcoded Source:"web", so the Android app's own pushes landed with last_sync_source/last_modified_source "web" — the app was indistinguishable from the web SPA in progress conflicts, device_sync_data, and annotation bookkeeping. The app CAN say who it is (bookmark create already carried origin → origin_source); the server just ignored it for labeling. All six write sites (progress PUT, bookmark/note/highlight create+PUT) now parse an ADDITIVE optional "origin" key, normalized through one gate: "android"/"ios" pass through, anything absent/unknown stays "web" (the label keys conflict claims and device_sync_data — free-form client strings would pollute them). Precedence on progress: a paired X-Device-Token still outranks origin. Bookmark origin_source now rides the same gate. The web SPA sends no origin — its rows are unchanged; KOReader/Kobo handlers keep their own labels. Wire discipline: additive request keys only (rule 2), response shapes untouched, pins changelog widened in-commit, locked by TestOriginWriterAttribution (android lands, default stays web, unknown normalizes). Full battery: only the two known pre-existing failures (TestOPDSLibraryFolders rot, TestWebSocketPingPong — both reproduce with the change stashed).
This commit is contained in:
1 parent
8f05f1116e
commit
45b8542e25
4 files changed
+130
-10
No files matched your search
@@ -1310,3 +1310,81 @@ func TestH3_BookmarkPUTPreservesLocators(t *testing.T) {
|
||||
require.Equal(t, "web", getStringField(t, noteRow, "last_modified_source"),
|
||||
"the note PUT must maintain source bookkeeping via the sync service")
|
||||
}
|
||||
|
||||
// TestOriginWriterAttribution locks the client-origin labeling: every
|
||||
// web-API write endpoint accepts an optional `origin` key and records it
|
||||
// as the writer (last_modified_source / last_sync_source) when it names a
|
||||
// known first-party client ("android", "ios"). Absent or unknown values
|
||||
// stay "web", and a paired X-Device-Token still outranks origin on the
|
||||
// progress push. The web SPA sends no origin — its rows are unchanged.
|
||||
func TestOriginWriterAttribution(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
setup := setupTestServer(t)
|
||||
fx := createContractFixture(t, setup)
|
||||
|
||||
t.Run("progress PUT honors origin, defaults to web", func(t *testing.T) {
|
||||
url := progressURL(setup.Server.URL, fx.itemID)
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.3, "origin": "android",
|
||||
}, setup.RegularToken)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
putRow := decodeJSON(t, resp)
|
||||
require.Equal(t, "android", getStringField(t, putRow, "last_sync_source"))
|
||||
|
||||
resp2 := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.31,
|
||||
}, setup.RegularToken)
|
||||
defer resp2.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
require.Equal(t, "web", getStringField(t, decodeJSON(t, resp2), "last_sync_source"))
|
||||
|
||||
// Unknown labels normalize to web (the label keys conflict claims
|
||||
// and device_sync_data — free-form client strings would pollute).
|
||||
resp3 := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.32, "origin": "fridge",
|
||||
}, setup.RegularToken)
|
||||
defer resp3.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp3.StatusCode)
|
||||
require.Equal(t, "web", getStringField(t, decodeJSON(t, resp3), "last_sync_source"))
|
||||
})
|
||||
|
||||
t.Run("bookmark create and PUT honor origin", func(t *testing.T) {
|
||||
url := annotationURL(setup.Server.URL, fx.itemID, "bookmarks", "")
|
||||
resp := doReq(t, "POST", url, map[string]interface{}{
|
||||
"title": "origin bookmark", "position": "p1", "origin": "android",
|
||||
}, setup.RegularToken)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
row := decodeJSON(t, resp)
|
||||
id, _ := row["id"].(string)
|
||||
require.NotEmpty(t, id)
|
||||
require.Equal(t, "android", getStringField(t, row, "last_modified_source"))
|
||||
require.Equal(t, "android", getStringField(t, row, "origin_source"))
|
||||
|
||||
put := doReq(t, "PUT", annotationURL(setup.Server.URL, fx.itemID, "bookmarks", id), map[string]interface{}{
|
||||
"title": "origin bookmark renamed", "origin": "android",
|
||||
}, setup.RegularToken)
|
||||
defer put.Body.Close()
|
||||
require.Equal(t, http.StatusOK, put.StatusCode)
|
||||
require.Equal(t, "android", getStringField(t, decodeJSON(t, put), "last_modified_source"))
|
||||
})
|
||||
|
||||
t.Run("note and highlight creates honor origin", func(t *testing.T) {
|
||||
noteResp := doReq(t, "POST", annotationURL(setup.Server.URL, fx.itemID, "notes", ""), map[string]interface{}{
|
||||
"content": "origin note", "position": "p2", "origin": "android",
|
||||
}, setup.RegularToken)
|
||||
defer noteResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, noteResp.StatusCode)
|
||||
require.Equal(t, "android", getStringField(t, decodeJSON(t, noteResp), "last_modified_source"))
|
||||
|
||||
hlResp := doReq(t, "POST", annotationURL(setup.Server.URL, fx.itemID, "highlights", ""), map[string]interface{}{
|
||||
"selection_text": "origin highlight", "origin": "ios",
|
||||
}, setup.RegularToken)
|
||||
defer hlResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, hlResp.StatusCode)
|
||||
require.Equal(t, "ios", getStringField(t, decodeJSON(t, hlResp), "last_modified_source"))
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user