Artifact fb3629d3a8ae6893c0232c38d574617815885e73d0175c47910c87f2c39164c6:
- File
internal/sync/handler_login_test.go
— part of check-in
[5f4424320b]
at
2026-10-13 03:56:38
on branch main
— fix(sync): end card processing on refused writes and logins, count errors as fossil (#374) (#382)
Three server refusal paths now follow sync-protocol-01:
- Section 6.6 (erratum s6.6-file-card-after-refused-push): a file or cfile card without an accepted push resets the reply, sends "not authorized to write" and ends card processing, as page_xfer does. The pull's igot inventory still follows. - Section 6.4: a refused login ends card processing at its own position rather than ending the request. Cards before it keep their effect, the end-of-request inventory runs, and the reply carries the closing timestamp comment. - Section 5.3 (erratum s5.3-errors-count): errors N counts errors raised, including those a reset discarded, and skips the bad-command error, the private-content refusal and the SHA3 refusal. (user: [email protected], size: 9245) [annotate] [blame] [check-ins using]
package sync import ( "context" "strings" "testing" "github.com/danmestas/go-libfossil/internal/auth" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/xfer" ) // signedRequest returns the request a client logged in as user would send: // cards behind a login card whose nonce covers their wire bytes, decoded the // way the server decodes a request, so the login card carries its tail. func signedRequest( t *testing.T, user, password, projectCode string, cards ...xfer.Card, ) *xfer.Message { t.Helper() payload, err := encodeCards(cards) if err != nil { t.Fatalf("encodeCards: %v", err) } return wireRequest(t, computeLogin(user, password, projectCode, payload), cards...) } // wireRequest puts login ahead of cards, encodes the message and decodes it // again, as the request arrives at a server. func wireRequest(t *testing.T, login *xfer.LoginCard, cards ...xfer.Card) *xfer.Message { t.Helper() all := append([]xfer.Card{login}, cards...) wire, err := (&xfer.Message{Cards: all}).Encode() if err != nil { t.Fatalf("Encode: %v", err) } msg, err := xfer.Decode(wire, xfer.ContentTypeCompressed) if err != nil { t.Fatalf("Decode: %v", err) } return msg } // loginRepo returns a repo whose nobody may only pull and whose "alice" may // only push, so each test can tell which capabilities a request ran with. func loginRepo(t *testing.T) (*repo.Repo, string) { t.Helper() r := setupSyncTestRepo(t) pc := testProjectCode(t, r.DB()) if _, err := r.DB().Exec("UPDATE user SET cap='o' WHERE login='nobody'"); err != nil { t.Fatalf("set nobody caps: %v", err) } if err := auth.CreateUser(r.DB(), pc, "alice", "secret", "i"); err != nil { t.Fatalf("CreateUser: %v", err) } return r, pc } // requireLoginRefused asserts that resp is the whole of a refused login that // leads its request: the one error card fossil sends (src/xfer.c page_xfer // resets the reply, emits "error login\sfailed" and breaks), nothing // processed after it, and the closing comment counting that error (§6.4). func requireLoginRefused(t *testing.T, resp *xfer.Message, want string) { t.Helper() if len(resp.Cards) != 2 { t.Fatalf("reply has %d cards, want the error and the comment: %#v", len(resp.Cards), resp.Cards) } ec, ok := resp.Cards[0].(*xfer.ErrorCard) if !ok || ec.Message != want { t.Fatalf("reply = %#v, want error %q", resp.Cards[0], want) } cc, ok := resp.Cards[1].(*xfer.CommentCard) if !ok || !strings.HasPrefix(cc.Text, "timestamp ") || !strings.HasSuffix(cc.Text, " errors 1") { t.Fatalf("reply ends with %#v, want the timestamp comment with errors 1", resp.Cards[1]) } } // A login card captured from one request must not authenticate another body. // The nonce is the SHA-1 of the body that followed the login line (§6.2), and // the server has to check it, or the signature proves nothing about the body. func TestHandleReplayedLoginRefused(t *testing.T) { r, pc := loginRepo(t) captured := signedRequest(t, "alice", "secret", pc, &xfer.PullCard{ServerCode: "sc", ProjectCode: pc}, ).Cards[0].(*xfer.LoginCard) data := []byte("pushed under a replayed login") replay := wireRequest(t, captured, &xfer.PushCard{ServerCode: "sc", ProjectCode: pc}, &xfer.FileCard{UUID: hash.SHA1(data), Content: data}, ) resp, err := HandleSync(context.Background(), r, replay) if err != nil { t.Fatalf("HandleSync: %v", err) } requireLoginRefused(t, resp, "login failed") if _, ok := blob.Exists(r.DB(), hash.SHA1(data)); ok { t.Fatal("a replayed login card stored a pushed file") } } // A login card that was never decoded from a request has no body tail, so it // cannot vouch for one. func TestHandleUndecodedLoginRefused(t *testing.T) { r, pc := loginRepo(t) payload := []byte("anything") req := &xfer.Message{Cards: []xfer.Card{ computeLogin("alice", "secret", pc, payload), &xfer.PullCard{ServerCode: "sc", ProjectCode: pc}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } requireLoginRefused(t, resp, "login failed") } // A failed login ends card processing: fossil does not carry on as nobody. func TestHandleFailedLoginEndsCards(t *testing.T) { r, pc := loginRepo(t) storeTestBlob(t, r, []byte("visible to nobody")) req := signedRequest(t, "alice", "wrong", pc, &xfer.PullCard{ServerCode: "sc", ProjectCode: pc}, ) resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } requireLoginRefused(t, resp, "login failed") } // A successful login adds the user's capabilities to nobody's; it does not // replace them (src/xfer.c check_login calls login_set_capabilities, which // only sets flags). alice may push, nobody may pull, so alice may do both. func TestHandleLoginAddsToNobodyCaps(t *testing.T) { r, pc := loginRepo(t) storeTestBlob(t, r, []byte("already on the server")) data := []byte("pushed by alice") req := signedRequest(t, "alice", "secret", pc, &xfer.PushCard{ServerCode: "sc", ProjectCode: pc}, &xfer.PullCard{ServerCode: "sc", ProjectCode: pc}, &xfer.FileCard{UUID: hash.SHA1(data), Content: data}, ) resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } if errs := findCards[*xfer.ErrorCard](resp); len(errs) > 0 { t.Fatalf("unexpected error: %s", errs[0].Message) } if _, ok := blob.Exists(r.DB(), hash.SHA1(data)); !ok { t.Fatal("alice's push was not stored") } if len(findCards[*xfer.IGotCard](resp)) == 0 { t.Fatal("alice lost nobody's pull capability by logging in") } } // One login per request (§6.1). A second card is fatal even when both verify. func TestHandleMultipleLoginCardsRefused(t *testing.T) { r, pc := loginRepo(t) pull := &xfer.PullCard{ServerCode: "sc", ProjectCode: pc} payload, err := encodeCards([]xfer.Card{pull}) if err != nil { t.Fatalf("encodeCards: %v", err) } second := computeLogin("alice", "secret", pc, payload) req := signedRequest(t, "alice", "secret", pc, second, pull) resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } requireLoginRefused(t, resp, "multiple login cards") } // Anybody may sync as nobody or anonymous (src/xfer.c check_login): the login // succeeds without a password and leaves nobody's capabilities in force. func TestHandleAnonymousLoginKeepsNobodyCaps(t *testing.T) { r, pc := loginRepo(t) storeTestBlob(t, r, []byte("visible to nobody")) req := signedRequest(t, "anonymous", "", pc, &xfer.PullCard{ServerCode: "sc", ProjectCode: pc}, ) resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } if errs := findCards[*xfer.ErrorCard](resp); len(errs) > 0 { t.Fatalf("unexpected error: %s", errs[0].Message) } if len(findCards[*xfer.IGotCard](resp)) == 0 { t.Fatal("an anonymous login lost nobody's pull capability") } } // A user row with no password cannot be logged into (src/xfer.c check_login // requires length(pw)>0): its signature would be SHA1(nonce) alone, which // anyone can compute. func TestHandleEmptyPasswordLoginRefused(t *testing.T) { r, pc := loginRepo(t) if _, err := r.DB().Exec("UPDATE user SET pw='' WHERE login='alice'"); err != nil { t.Fatalf("clear pw: %v", err) } pull := &xfer.PullCard{ServerCode: "sc", ProjectCode: pc} payload, err := encodeCards([]xfer.Card{pull}) if err != nil { t.Fatalf("encodeCards: %v", err) } nonce := sha1Hex(payload) login := &xfer.LoginCard{User: "alice", Nonce: nonce, Signature: sha1Hex([]byte(nonce))} resp, err := HandleSync(context.Background(), r, wireRequest(t, login, pull)) if err != nil { t.Fatalf("HandleSync: %v", err) } requireLoginRefused(t, resp, "login failed") } // A pull ahead of a refused login keeps its effect: page_xfer refuses the // login in card order and breaks, and the pull's igot inventory still // follows the error, as do the closing comment (§6.4). func TestHandleLoginRefusedAfterPull(t *testing.T) { r, pc := loginRepo(t) uuid := storeTestBlob(t, r, []byte("visible to nobody")) login := computeLogin("alice", "wrong", pc, []byte("anything")) req := &xfer.Message{Cards: []xfer.Card{ &xfer.PullCard{ServerCode: "sc", ProjectCode: pc}, login, &xfer.GimmeCard{UUID: uuid}, }} resp, err := HandleSync(context.Background(), r, req) if err != nil { t.Fatalf("HandleSync: %v", err) } errs := findCards[*xfer.ErrorCard](resp) if len(errs) != 1 || errs[0].Message != "login failed" { t.Fatalf("error cards %v, want one \"login failed\"", errs) } if files := findCards[*xfer.FileCard](resp); len(files) != 0 { t.Fatalf("the gimme after the refused login was answered: %v", files) } igots := findCards[*xfer.IGotCard](resp) if len(igots) != 1 || igots[0].UUID != uuid { t.Fatalf("igot cards %v, want the pull's inventory of %s", igots, uuid) } last, ok := resp.Cards[len(resp.Cards)-1].(*xfer.CommentCard) if !ok || !strings.HasSuffix(last.Text, " errors 1") { t.Fatalf("reply ends with %#v, want the comment with errors 1", resp.Cards[len(resp.Cards)-1]) } }