Files
lodge/backend/internal/server/server_test.go
T

242 lines
7.3 KiB
Go

package server
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"lodge/internal/protocol"
)
const (
adminToken = "admin"
repoKey = "0123456789abcdef0123456789abcdef01234567"
)
func newServer(t *testing.T) string {
t.Helper()
store, err := OpenStore(filepath.Join(t.TempDir(), "lodge.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
api := httptest.NewServer(New(store, Config{AdminToken: adminToken}))
t.Cleanup(api.Close)
return api.URL
}
func call(t *testing.T, method, url, token string, in, out any) {
t.Helper()
var body bytes.Buffer
if in != nil {
_ = json.NewEncoder(&body).Encode(in)
}
req, _ := http.NewRequest(method, url, &body)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode >= http.StatusBadRequest {
t.Fatalf("%s %s: %s", method, url, res.Status)
}
if out != nil {
if err := json.NewDecoder(res.Body).Decode(out); err != nil {
t.Fatal(err)
}
}
}
func push(t *testing.T, url, author, path, base, content string) string {
t.Helper()
sum := sha256.Sum256([]byte(content))
hash := hex.EncodeToString(sum[:])
req, _ := http.NewRequest(http.MethodPut, url+"/api/blobs/"+hash, strings.NewReader(content))
req.Header.Set("Authorization", "Bearer "+adminToken)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
change := protocol.Change{Space: protocol.SharedSpace, Path: path, Base: base, Hash: hash}
call(t, http.MethodPost, url+"/api/repos/"+repoKey+"/revisions", adminToken, protocol.Push{Name: "vault", Author: author, Changes: []protocol.Change{change}}, nil)
return hash
}
func revisions(t *testing.T, url, query string) []protocol.Revision {
t.Helper()
var revisions []protocol.Revision
call(t, http.MethodGet, url+"/api/repos/"+repoKey+"/revisions"+query, adminToken, nil, &revisions)
return revisions
}
func seqs(revisions []protocol.Revision) []int64 {
seqs := make([]int64, len(revisions))
for i, rev := range revisions {
seqs[i] = rev.Seq
}
return seqs
}
func TestRevisionsCarryAuthorAndPageAfterSeq(t *testing.T) {
url := newServer(t)
events, err := http.NewRequest(http.MethodGet, url+"/api/events", nil)
if err != nil {
t.Fatal(err)
}
events.Header.Set("Authorization", "Bearer "+adminToken)
stream, err := http.DefaultClient.Do(events)
if err != nil {
t.Fatal(err)
}
defer stream.Body.Close()
first := push(t, url, "", "a.md", "", "one")
push(t, url, "alice", "a.md", first, "two")
push(t, url, "bob", "b.md", "", "three")
if got := seqs(revisions(t, url, "")); len(got) != 3 || got[0] != 3 {
t.Fatalf("default order = %v, want newest first", got)
}
after := revisions(t, url, "?after=1")
if got := seqs(after); len(got) != 2 || got[0] != 2 || got[1] != 3 {
t.Fatalf("after=1 = %v, want [2 3]", got)
}
if after[0].Author != "alice" || after[0].Machine != "web" || revisions(t, url, "?after=0")[0].Author != "" {
t.Fatalf("authors = %+v", after)
}
if got := seqs(revisions(t, url, "?after=0&path=b.md")); len(got) != 1 || got[0] != 3 {
t.Fatalf("after with path = %v", got)
}
if got := revisions(t, url, "?after=3"); len(got) != 0 {
t.Fatalf("after the last revision = %v", got)
}
var authors []string
scanner := bufio.NewScanner(stream.Body)
for len(authors) < 3 && scanner.Scan() {
if data, ok := strings.CutPrefix(scanner.Text(), "data: "); ok {
var event protocol.Event
_ = json.Unmarshal([]byte(data), &event)
authors = append(authors, event.Author)
}
}
if strings.Join(authors, ",") != ",alice,bob" {
t.Fatalf("event authors = %q", authors)
}
}
func TestOldDatabaseGainsAuthorColumn(t *testing.T) {
path := filepath.Join(t.TempDir(), "lodge.db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(`
create table revisions (repo text not null, seq integer not null, machine_id integer, time integer not null, main_sha text not null, main_branch text not null, primary key (repo, seq));
create table changes (repo text not null, seq integer not null, space text not null, path text not null, base text not null, hash text not null, primary key (repo, seq, space, path));
insert into revisions values ('` + repoKey + `', 1, null, 1, '', 'main');
insert into changes values ('` + repoKey + `', 1, 'shared', 'a.md', '', 'x');`)
if err := errors.Join(err, db.Close()); err != nil {
t.Fatal(err)
}
for range 2 {
store, err := OpenStore(path)
if err != nil {
t.Fatal(err)
}
revisions, err := store.Revisions(context.Background(), repoKey, "")
store.Close()
if err != nil || len(revisions) != 1 || revisions[0].Author != "" {
t.Fatalf("old revisions = %+v (%v)", revisions, err)
}
}
}
func TestMachinesShowWhoIsOnline(t *testing.T) {
url := newServer(t)
var code protocol.CodeResponse
call(t, http.MethodPost, url+"/api/codes", adminToken, nil, &code)
var claim protocol.ClaimResponse
call(t, http.MethodPost, url+"/api/claim", "", protocol.ClaimRequest{Code: code.Code, Name: "dell"}, &claim)
online := func() protocol.Machine {
t.Helper()
var machines []protocol.Machine
call(t, http.MethodGet, url+"/api/machines", adminToken, nil, &machines)
if len(machines) != 1 {
t.Fatalf("machines = %+v", machines)
}
return machines[0]
}
if machine := online(); machine.Online || machine.LastSeen != 0 {
t.Fatalf("a machine that never connected: %+v", machine)
}
ctx, cancel := context.WithCancel(context.Background())
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url+"/api/events", nil)
req.Header.Set("Authorization", "Bearer "+claim.Token)
stream, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if machine := online(); !machine.Online || machine.LastSeen == 0 {
t.Fatalf("a machine listening for events: %+v", machine)
}
cancel()
stream.Body.Close()
deadline := time.Now().Add(5 * time.Second)
for online().Online {
if time.Now().After(deadline) {
t.Fatal("the machine stays online after its stream closed")
}
time.Sleep(20 * time.Millisecond)
}
}
func TestInstallScriptModes(t *testing.T) {
url := newServer(t)
var code protocol.CodeResponse
call(t, http.MethodPost, url+"/api/codes", adminToken, nil, &code)
if code.RemoteCommand != "curl -fsSL "+url+"/i/"+code.Code+"/remote | sh" {
t.Fatalf("remote command = %q", code.RemoteCommand)
}
for path, mode := range map[string]string{"/i/" + code.Code: "login", "/i/" + code.Code + "/remote": "remote"} {
res, err := http.Get(url + path)
if err != nil {
t.Fatal(err)
}
script, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode != http.StatusOK || !strings.Contains(string(script), `mode="`+mode+`"`) || !strings.Contains(string(script), `code="`+code.Code+`"`) {
t.Fatalf("%s: %d\n%s", path, res.StatusCode, script)
}
check := exec.Command("sh", "-n")
check.Stdin = bytes.NewReader(script)
if out, err := check.CombinedOutput(); err != nil {
t.Fatalf("%s is not valid sh: %v %s", path, err, out)
}
}
res, err := http.Get(url + "/i/abcdef/remote")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusNotFound {
t.Fatalf("unknown code: %d", res.StatusCode)
}
}