big update in share links
This commit is contained in:
@@ -60,6 +60,7 @@ def main() -> None:
|
||||
migrations = sorted(MIGRATIONS.glob("*.sql"))
|
||||
before_sessions = [path for path in migrations if path.name < "0026_share_link_sessions.sql"]
|
||||
session_migration = MIGRATIONS / "0026_share_link_sessions.sql"
|
||||
label_migration = MIGRATIONS / "0027_share_link_labels.sql"
|
||||
|
||||
db = sqlite3.connect(":memory:")
|
||||
db.execute("PRAGMA foreign_keys = ON")
|
||||
@@ -74,14 +75,15 @@ def main() -> None:
|
||||
share_hash = hashlib.sha256(raw_link.encode()).hexdigest()
|
||||
db.execute(
|
||||
"INSERT INTO resource_share_links "
|
||||
"(token_hash, token, resource_kind, resource_slug, permission, expires_at, created_by) "
|
||||
"VALUES (?, ?, 'workspace', 'private-space', 'ro', NULL, 1)",
|
||||
(share_hash, raw_link),
|
||||
"(token_hash, resource_kind, resource_slug, permission, expires_at, created_by) "
|
||||
"VALUES (?, 'workspace', 'private-space', 'ro', NULL, 1)",
|
||||
(share_hash,),
|
||||
)
|
||||
db.executescript(session_migration.read_text())
|
||||
assert db.execute(
|
||||
"SELECT token FROM resource_share_links WHERE token_hash = ?", (share_hash,)
|
||||
).fetchone() == (None,), "migration must erase persisted plaintext share tokens"
|
||||
db.executescript(label_migration.read_text())
|
||||
columns = {row[1] for row in db.execute("PRAGMA table_info(resource_share_links)")}
|
||||
assert "token" not in columns, "fresh schema must not contain a plaintext token column"
|
||||
assert "label" in columns, "share links must support labels"
|
||||
|
||||
source_sql = query("SHARE_LINK_SESSION_SOURCE")
|
||||
insert_sql = query("SHARE_SESSION_INSERT")
|
||||
@@ -162,23 +164,24 @@ def main() -> None:
|
||||
).rowcount
|
||||
assert deleted == 1, "revoking a link must remove all derived sessions"
|
||||
|
||||
# Newly created links persist only the hash; the plaintext column stays NULL.
|
||||
# Newly created links persist only the hash and an optional identifying label.
|
||||
second_raw = "one-time-returned-token"
|
||||
second_hash = hashlib.sha256(second_raw.encode()).hexdigest()
|
||||
db.execute(
|
||||
link_insert_sql,
|
||||
(second_hash, "workspace", "second-space", "rw", None, 1),
|
||||
(second_hash, "QA link", "workspace", "second-space", "rw", None, 1),
|
||||
)
|
||||
assert db.execute(
|
||||
"SELECT token, permission FROM resource_share_links WHERE token_hash = ?", (second_hash,)
|
||||
).fetchone() == (None, "rw")
|
||||
"SELECT label, permission FROM resource_share_links WHERE token_hash = ?", (second_hash,)
|
||||
).fetchone() == ("QA link", "rw")
|
||||
listed_link = db.execute(sharing_list_sql, ("workspace", "second-space")).fetchone()
|
||||
assert listed_link == (
|
||||
second_hash,
|
||||
"QA link",
|
||||
"rw",
|
||||
None,
|
||||
listed_link[3],
|
||||
), "management listing must never return the plaintext share token"
|
||||
listed_link[4],
|
||||
), "management listing must return the label but never a plaintext share token"
|
||||
|
||||
db.execute(
|
||||
insert_sql,
|
||||
|
||||
@@ -25,7 +25,8 @@ def query_sql(backend: str, name: str) -> str:
|
||||
|
||||
def main() -> None:
|
||||
names = {
|
||||
"SHARE_LINK_INSERT": 6,
|
||||
"SHARE_LINK_INSERT": 7,
|
||||
"SHARE_LINK_UPDATE": 6,
|
||||
"SHARE_LINK_PERMISSION": 3,
|
||||
"SHARE_LINK_SESSION_SOURCE": 3,
|
||||
"SHARE_SESSION_INSERT": 5,
|
||||
@@ -43,6 +44,8 @@ def main() -> None:
|
||||
assert sorted(set(parameters)) == list(range(1, expected + 1)), (name, sql)
|
||||
|
||||
auth = read("src/auth/mod.rs")
|
||||
register_response = auth[auth.index("pub struct RegisterResponse"):auth.index("pub async fn identity")]
|
||||
assert "token:" not in register_response
|
||||
assert ".bind(&token)" not in auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
|
||||
assert '"token":row.token' not in auth
|
||||
assert "token: Option<String>" not in auth[auth.index("struct SharingLinkRow"):auth.index("struct ShareLinkSessionSource")]
|
||||
@@ -55,10 +58,29 @@ def main() -> None:
|
||||
assert 'format!("share-session-client:{client_key}")' in auth
|
||||
create_link = auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
|
||||
assert "no-store, max-age=0" in create_link
|
||||
assert '"token_hash":token_hash' in create_link
|
||||
assert '"token":token' not in create_link
|
||||
assert '"label":label' in create_link
|
||||
create_session = auth[auth.index("pub async fn create_share_session"):auth.index("async fn share_session_permission")]
|
||||
assert "existing_session_token" in create_session
|
||||
assert 'source.permission == "ro"' in create_session
|
||||
assert '== Some("rw")' in create_session
|
||||
assert "existing_session_token" not in create_session
|
||||
assert 'source.permission == "ro"' not in create_session
|
||||
assert "resource_is_public_unprotected(state, kind, slug).await?" in create_session
|
||||
assert create_session.index("resource_is_public_unprotected") < create_session.index("valid_share_token")
|
||||
create_link = auth[auth.index("pub async fn create_share_link"):auth.index("pub async fn update_share_link")]
|
||||
update_link = auth[auth.index("pub async fn update_share_link"):auth.index("pub async fn revoke_share_link")]
|
||||
revoke_link = auth[auth.index("pub async fn revoke_share_link"):auth.index("fn validate_permission")]
|
||||
for management_handler in (create_link, update_link, revoke_link):
|
||||
assert "ensure_share_links_enabled" in management_handler
|
||||
assert "Direct share links are disabled for public resources without a password." in auth
|
||||
share_access = auth[auth.index("pub async fn share_access_permission"):auth.index("pub async fn share_link_permission")]
|
||||
assert "resource_is_public_unprotected(state, kind, slug).await?" in share_access
|
||||
sharing = auth[auth.index("pub async fn resource_sharing"):auth.index("pub async fn create_share_link")]
|
||||
assert '"share_links_enabled":share_links_enabled' in sharing
|
||||
assert "if share_links_enabled" in sharing
|
||||
assert "Vec::new()" in sharing
|
||||
privacy = auth[auth.index("pub async fn set_resource_privacy"):auth.index("pub async fn share_resource_users")]
|
||||
assert "SHARE_LINK" not in privacy
|
||||
assert "SHARE_SESSION" not in privacy
|
||||
|
||||
api = read("src/api/mod.rs")
|
||||
assert "combined_token_access_level" not in api
|
||||
@@ -68,7 +90,16 @@ def main() -> None:
|
||||
|
||||
access_tokens = read("src/api/access_tokens.rs")
|
||||
assert "verify_password_access_token" in access_tokens
|
||||
assert "share_access_permission" in access_tokens
|
||||
assert "verify_resource_access_token" not in access_tokens
|
||||
assert "workspace.password_hash.is_some()" in access_tokens
|
||||
assert "pad.password_hash.is_some()" in access_tokens
|
||||
|
||||
database = read("src/db/mod.rs")
|
||||
workspace_password = database[database.index("pub fn verify_workspace_password"):database.index("pub async fn list_notes")]
|
||||
pad_password = database[database.index("pub fn verify_pad_password"):database.index("pub async fn save_pad_revision")]
|
||||
assert "(None, _) => false" in workspace_password
|
||||
assert "(None, _) => false" in pad_password
|
||||
assert "missing_password_does_not_grant_password_access" in database
|
||||
|
||||
websocket = read("src/websocket/mod.rs") + read("src/websocket/pad.rs")
|
||||
assert "cookie_share_session_token" in websocket
|
||||
@@ -79,6 +110,9 @@ def main() -> None:
|
||||
assert "access_refresh" in websocket
|
||||
assert websocket.count("update=updates.recv()=>{") == 2
|
||||
assert websocket.count('message:"Access expired or revoked"') >= 6
|
||||
current_access = read("src/websocket/mod.rs")[read("src/websocket/mod.rs").index("async fn current_resource_access"):read("src/websocket/mod.rs").index("// Merged from note.rs")]
|
||||
assert "resource_is_public_unprotected(state, kind, slug)" in current_access
|
||||
assert "public_unprotected: bool" not in current_access
|
||||
|
||||
pages = read("src/app/pages.rs")
|
||||
assert "canonical_resource_url" in pages
|
||||
@@ -95,20 +129,40 @@ def main() -> None:
|
||||
security = read("src/security.rs")
|
||||
assert "__Host-rustpad_share_" in security
|
||||
assert "HttpOnly; Secure; SameSite=Lax" in security
|
||||
assert "clear_share_session_cookie" not in security
|
||||
|
||||
for backend in ("sqlite", "postgres", "mysql"):
|
||||
sharing_list = query_sql(backend, "RESOURCE_SHARING_LINKS")
|
||||
assert not re.search(r"(?:^|,)\s*(?:CAST\()?token\b", sharing_list)
|
||||
legacy_migration = read(f"migrations/{backend}/0012_share_link_tokens.sql")
|
||||
assert "ADD COLUMN token" not in legacy_migration
|
||||
migration = read(f"migrations/{backend}/0026_share_link_sessions.sql")
|
||||
assert "UPDATE resource_share_links SET token = NULL" in migration
|
||||
assert "resource_share_links SET token" not in migration
|
||||
assert "session_token_hash" in migration
|
||||
assert "ON DELETE CASCADE" in migration
|
||||
label_migration = read(f"migrations/{backend}/0027_share_link_labels.sql")
|
||||
assert "ADD COLUMN label" in label_migration
|
||||
|
||||
home_js = read("static/js/home.js")
|
||||
assert "createdLinkValues" in home_js
|
||||
assert "result.token_hash" in home_js
|
||||
assert "data-copy-link" in home_js
|
||||
assert "The full address is not stored" in home_js
|
||||
assert 'name="label"' in home_js
|
||||
assert "token_hash: linkRow.dataset.linkTokenHash" in home_js
|
||||
assert "Link created, displayed below and copied." in home_js
|
||||
assert "data-direct-links-disabled" in home_js
|
||||
assert "d.share_links_enabled !== false" in home_js
|
||||
assert "Existing links are preserved and become active again" in home_js
|
||||
|
||||
workspace_js = read("static/js/workspace.js")
|
||||
note_js = read("static/js/note-editor.js")
|
||||
assert 'info.access_level === "none"' in workspace_js
|
||||
assert note_js.count('info.access_level === "none"') >= 2
|
||||
|
||||
main_rs = read("src/main.rs")
|
||||
assert "https://git.linuxiarz.pl/gru/rustpad/src/branch/master/LICENSE.md" in main_rs
|
||||
|
||||
app = read("src/app/mod.rs")
|
||||
assert "PathOnlyMakeSpan" in app
|
||||
assert "request.uri().path()" in app
|
||||
|
||||
+71
-48
@@ -2,66 +2,89 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
globalThis.location = { origin: "https://pad.example" };
|
||||
const origin = "https://pad.example";
|
||||
let currentUrl;
|
||||
let historyCall;
|
||||
let dispatchedEvent;
|
||||
|
||||
globalThis.CustomEvent = class CustomEvent {
|
||||
constructor(type, init = {}) {
|
||||
this.type = type;
|
||||
this.detail = init.detail;
|
||||
}
|
||||
};
|
||||
|
||||
globalThis.window = {
|
||||
location: null,
|
||||
history: {
|
||||
pushState(state, _title, url) {
|
||||
historyCall = { method: "pushState", state };
|
||||
setLocation(url);
|
||||
},
|
||||
replaceState(state, _title, url) {
|
||||
historyCall = { method: "replaceState", state };
|
||||
setLocation(url);
|
||||
},
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
dispatchedEvent = event;
|
||||
},
|
||||
};
|
||||
|
||||
function setLocation(value) {
|
||||
currentUrl = new URL(value, origin);
|
||||
window.location = {
|
||||
get href() { return currentUrl.href; },
|
||||
get search() { return currentUrl.search; },
|
||||
};
|
||||
}
|
||||
|
||||
function resetWindow(value) {
|
||||
setLocation(value);
|
||||
historyCall = undefined;
|
||||
dispatchedEvent = undefined;
|
||||
}
|
||||
|
||||
const source = await readFile(new URL("../static/js/url-state.js", import.meta.url), "utf8");
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
||||
const { editorResourceUrl, withShareToken } = await import(moduleUrl);
|
||||
const { currentShareUrl, readEditorState, writeEditorState } = await import(moduleUrl);
|
||||
|
||||
function parsed(path) {
|
||||
return new URL(path, "https://pad.example");
|
||||
}
|
||||
|
||||
test("workspace share token is preserved when opening a note", () => {
|
||||
const url = parsed(editorResourceUrl("/w/private/n/first", {
|
||||
shareToken: "share-token-123",
|
||||
view: "split",
|
||||
mode: "markdown",
|
||||
}));
|
||||
|
||||
assert.equal(url.pathname, "/w/private/n/first");
|
||||
assert.equal(url.searchParams.get("share"), "share-token-123");
|
||||
assert.equal(url.searchParams.get("view"), "split");
|
||||
assert.equal(url.searchParams.get("mode"), "markdown");
|
||||
test("editor state reads supported values", () => {
|
||||
resetWindow("/w/demo/n/note?view=preview&mode=text");
|
||||
assert.deepEqual(readEditorState(), { view: "preview", mode: "text" });
|
||||
});
|
||||
|
||||
test("share token is preserved when returning to the workspace", () => {
|
||||
const url = parsed(withShareToken("/w/private", "share-token-123"));
|
||||
|
||||
assert.equal(url.pathname, "/w/private");
|
||||
assert.equal(url.searchParams.get("share"), "share-token-123");
|
||||
test("editor state rejects unsupported values", () => {
|
||||
resetWindow("/w/demo/n/note?view=invalid&mode=html");
|
||||
assert.deepEqual(readEditorState(), { view: "split", mode: "markdown" });
|
||||
});
|
||||
|
||||
test("existing query and hash survive share-aware navigation", () => {
|
||||
const url = parsed(editorResourceUrl("/w/private/n/first?mode=text#section", {
|
||||
shareToken: "new-token",
|
||||
view: "preview",
|
||||
mode: "markdown",
|
||||
}));
|
||||
test("writing editor state preserves unrelated query and hash", () => {
|
||||
resetWindow("/w/demo/n/note?filter=recent#section");
|
||||
const result = new URL(writeEditorState({ view: "edit", mode: "text" }));
|
||||
|
||||
assert.equal(url.searchParams.get("share"), "new-token");
|
||||
assert.equal(url.searchParams.get("view"), "preview");
|
||||
assert.equal(url.searchParams.get("mode"), "markdown");
|
||||
assert.equal(url.hash, "#section");
|
||||
assert.equal(result.searchParams.get("filter"), "recent");
|
||||
assert.equal(result.searchParams.get("view"), "edit");
|
||||
assert.equal(result.searchParams.get("mode"), "text");
|
||||
assert.equal(result.hash, "#section");
|
||||
assert.equal(historyCall.method, "pushState");
|
||||
assert.deepEqual(historyCall.state, { view: "edit", mode: "text" });
|
||||
assert.equal(dispatchedEvent.type, "rustpad:urlchange");
|
||||
});
|
||||
|
||||
test("ordinary workspace navigation does not gain or retain a share token", () => {
|
||||
const url = parsed(editorResourceUrl("/w/public/n/first?share=stale-token", {
|
||||
view: "split",
|
||||
mode: "markdown",
|
||||
}));
|
||||
|
||||
assert.equal(url.searchParams.has("share"), false);
|
||||
test("replace mode uses replaceState", () => {
|
||||
resetWindow("/p/demo");
|
||||
writeEditorState({ view: "split", mode: "markdown" }, { replace: true });
|
||||
assert.equal(historyCall.method, "replaceState");
|
||||
});
|
||||
|
||||
test("current share URL adds view state without inventing access tokens", () => {
|
||||
resetWindow("/w/demo/n/note?filter=recent#section");
|
||||
const result = new URL(currentShareUrl({ view: "preview", mode: "markdown" }));
|
||||
|
||||
test("share-aware helpers reject external application URLs", () => {
|
||||
assert.equal(withShareToken("https://example.com/steal", "secret"), "/");
|
||||
});
|
||||
|
||||
|
||||
test("share-aware helpers accept same-origin absolute URLs", () => {
|
||||
const url = parsed(withShareToken("https://pad.example/w/private", "share-token-123"));
|
||||
assert.equal(url.pathname, "/w/private");
|
||||
assert.equal(url.searchParams.get("share"), "share-token-123");
|
||||
assert.equal(result.searchParams.get("filter"), "recent");
|
||||
assert.equal(result.searchParams.get("view"), "preview");
|
||||
assert.equal(result.searchParams.get("mode"), "markdown");
|
||||
assert.equal(result.searchParams.has("share"), false);
|
||||
assert.equal(result.hash, "#section");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user