package main import ( "encoding/json" "net/http" "strconv" "strings" "testing" ) // sealLinkToken/openLinkToken round-trip, and a wrong key fails closed // (GCM authentication, not string comparison, decides "invalid"). func TestLinkTokenRoundTrip(t *testing.T) { key := make([]byte, 32) tok, err := sealLinkToken(key, "thread-abc", 42) if err != nil { t.Fatalf("seal: %v", err) } tid, mid, ok := openLinkToken(key, tok) if !ok || tid != "thread-abc" || mid != 42 { t.Fatalf("open: got tid=%q mid=%d ok=%v", tid, mid, ok) } otherKey := make([]byte, 32) otherKey[0] = 1 if _, _, ok := openLinkToken(otherKey, tok); ok { t.Fatal("token opened under the wrong key") } if _, _, ok := openLinkToken(key, "not-a-real-token"); ok { t.Fatal("garbage token should not open") } } // Two seals of the same pair produce different ciphertext (random nonce per // seal) — links to the same message aren't correlatable by their bytes alone. func TestLinkTokenNotCorrelatable(t *testing.T) { key := make([]byte, 32) a, _ := sealLinkToken(key, "thread-abc", 42) b, _ := sealLinkToken(key, "thread-abc", 42) if a == b { t.Fatal("two seals of the same pair should not be identical") } } // End-to-end: mint a link as a thread member, resolve it back to the same // ids. A non-member of the thread gets a generic not-found, same as an // invalid token — the endpoint must not be a membership oracle. func TestMintAndResolveMessageLink(t *testing.T) { ts, srv := newTestServer(t) tokA, uidA, tidA := joinedMember(t, srv, "Alice") tokB, _, _ := joinedMember(t, srv, "Bob") // a member of a different thread m, err := srv.db.insertMessage(tidA, "blob", uidA) if err != nil { t.Fatalf("insertMessage: %v", err) } mint := func(tok, threadID string, msgID int64) (string, int) { resp, err := http.Post( ts.URL+"/api/messages/"+strconv.FormatInt(msgID, 10)+"/link?token="+tok, "application/json", strings.NewReader(`{"thread_id":"`+threadID+`"}`), ) if err != nil { t.Fatalf("mint post: %v", err) } defer resp.Body.Close() var out struct { Token string `json:"token"` } json.NewDecoder(resp.Body).Decode(&out) return out.Token, resp.StatusCode } resolve := func(tok, linkToken string) (string, int64, int) { resp, err := http.Post(ts.URL+"/api/resolve-link?token="+tok, "application/json", strings.NewReader(`{"token":"`+linkToken+`"}`)) if err != nil { t.Fatalf("resolve post: %v", err) } defer resp.Body.Close() var out struct { ThreadID string `json:"thread_id"` MessageID int64 `json:"message_id"` } json.NewDecoder(resp.Body).Decode(&out) return out.ThreadID, out.MessageID, resp.StatusCode } linkTok, code := mint(tokA, tidA, m.ID) if code != http.StatusOK || linkTok == "" { t.Fatalf("mint: status=%d token=%q", code, linkTok) } if tid, mid, code := resolve(tokA, linkTok); code != http.StatusOK || tid != tidA || mid != m.ID { t.Fatalf("resolve as member: status=%d tid=%q mid=%d", code, tid, mid) } if _, _, code := resolve(tokB, linkTok); code != http.StatusNotFound { t.Fatalf("resolve as non-member: want 404, got %d", code) } if _, _, code := resolve(tokA, "garbage"); code != http.StatusNotFound { t.Fatalf("resolve garbage token: want 404, got %d", code) } // Bob can't mint a link for a message in a thread he's not in. if _, code := mint(tokB, tidA, m.ID); code != http.StatusForbidden { t.Fatalf("mint as non-member: want 403, got %d", code) } }