package store import ( "context" "errors" "path/filepath" "regexp" "sort" "strings" "testing" "time" "github.com/barerepo/server/internal/config" ) // A real ed25519 public key, and the private key a user will paste by mistake. const ( keyA = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH8fK2q0mR4vXeN1pLzT9wBcJdSgYo3Ea7kVnQxMuP2r laptop" keyB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDXWNJHVvNjWLqL0YB2Cbp3ObGZlAqNKvGD5f8ZKr8Kx uproar" priv = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----" ) // The tests run on SQLite for a fresh database each, and TestMigrationsAgree guards PostgreSQL. func open(t *testing.T) *DB { t.Helper() db, err := Open(context.Background(), "sqlite://"+filepath.Join(t.TempDir(), "barerepo.db")) if err != nil { t.Fatalf("Open: %v", err) } t.Cleanup(func() { db.Close() }) return db } func TestAccountLifecycle(t *testing.T) { ctx := context.Background() db := open(t) if _, err := db.CreateAccount(ctx, "john", keyA, false); err != nil { t.Fatalf("CreateAccount: %v", err) } if _, err := db.CreateAccount(ctx, "john", keyB, false); !errors.Is(err, ErrTaken) { t.Errorf("second john: got %v, want ErrTaken", err) } if _, err := db.CreateAccount(ctx, "lisa", keyA, false); !errors.Is(err, ErrTaken) { t.Errorf("reused key: got %v, want ErrTaken", err) } // Chapter 42.5: a name that shadows a route is not an account. if _, err := db.CreateAccount(ctx, "inbox", keyB, false); err == nil { t.Error("account named inbox was accepted") } // Chapter 27 and 45.4: the private key paste. if _, err := db.CreateAccount(ctx, "lisa", priv, false); err == nil || !strings.Contains(err.Error(), "private key") { t.Errorf("private key paste: got %v, want a message naming it", err) } a, err := db.Account(ctx, "john") if err != nil || a.Name != "john" || a.Admin { t.Fatalf("Account: %v %+v", err, a) } if _, err := db.Account(ctx, "nobody"); !errors.Is(err, ErrNotFound) { t.Errorf("missing account: got %v, want ErrNotFound", err) } // Chapter 10: an account with one key must not be able to lose it. keys, _ := db.Keys(ctx, "john") if len(keys) != 1 { t.Fatalf("Keys: got %d, want 1", len(keys)) } if err := db.DeleteKey(ctx, "john", keys[0].ID); err == nil { t.Error("deleting the only key was allowed") } if _, err := db.AddKey(ctx, "john", keyB); err != nil { t.Fatalf("AddKey: %v", err) } if err := db.DeleteKey(ctx, "john", keys[0].ID); err != nil { t.Errorf("DeleteKey with two keys: %v", err) } if keys, _ = db.Keys(ctx, "john"); len(keys) != 1 { t.Errorf("after delete: got %d keys, want 1", len(keys)) } } func TestMigrateIsIdempotent(t *testing.T) { ctx := context.Background() db := open(t) if err := db.migrate(ctx); err != nil { t.Fatalf("second migrate: %v", err) } var n int if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations`).Scan(&n); err != nil { t.Fatal(err) } if n != len(migrations) { t.Errorf("applied %d migrations, want %d", n, len(migrations)) } } // TestMigrationsAgree is what stops one hand-written dialect gaining a column alone. func TestMigrationsAgree(t *testing.T) { for i, m := range migrations { s, p := shape(m.sqlite), shape(m.postgres) if len(s) != len(p) { t.Fatalf("migration %d: sqlite has %v, postgres has %v", i+1, keysOf(s), keysOf(p)) } for table, cols := range s { other, ok := p[table] if !ok { t.Errorf("migration %d: postgres is missing table %s", i+1, table) continue } if strings.Join(cols, ",") != strings.Join(other, ",") { t.Errorf("migration %d: table %s\n sqlite: %v\n postgres: %v", i+1, table, cols, other) } } } } var ( tableRe = regexp.MustCompile(`(?is)CREATE TABLE (\w+) \((.*?)\);`) colRe = regexp.MustCompile(`(?m)^\s*(\w+)\s+\w`) ) // shape reduces DDL to table name -> sorted column names. func shape(ddl string) map[string][]string { out := map[string][]string{} for _, m := range tableRe.FindAllStringSubmatch(ddl, -1) { var cols []string for _, line := range strings.Split(m[2], "\n") { if strings.Contains(strings.ToUpper(line), "PRIMARY KEY (") { continue } if c := colRe.FindStringSubmatch(line); c != nil { cols = append(cols, c[1]) } } sort.Strings(cols) out[m[1]] = cols } return out } func keysOf(m map[string][]string) []string { var out []string for k := range m { out = append(out, k) } sort.Strings(out) return out } func TestDatabaseKind(t *testing.T) { cases := map[string]config.Kind{ "sqlite:///var/lib/barerepo/forge.db": config.SQLite, "postgres://barerepo@localhost/barerepo": config.Postgres, "postgresql://barerepo@localhost/barerepo": config.Postgres, } for url, want := range cases { got, err := config.Config{Database: config.Database{URL: url}}.DatabaseKind() if err != nil || got != want { t.Errorf("%s: got %q %v, want %q", url, got, err, want) } } for _, bad := range []string{"", "mysql://x", "/var/lib/barerepo/forge.db"} { if _, err := (config.Config{Database: config.Database{URL: bad}}).DatabaseKind(); err == nil { t.Errorf("%q was accepted", bad) } } } // The window has to fit leaving for a terminal and coming back, so this pins it. func TestChallengeLifeFitsAPerson(t *testing.T) { if ChallengeLife < 5*time.Minute { t.Errorf("ChallengeLife is %s, which is not enough time to switch to a terminal and back", ChallengeLife) } if ChallengeLife > time.Hour { t.Errorf("ChallengeLife is %s, which leaves a challenge open far longer than any sign-in takes", ChallengeLife) } } // A nonce works once, and a wrong signature spends it, so nothing is tried repeatedly. func TestChallengeIsSpentOnFirstUse(t *testing.T) { ctx := context.Background() db := open(t) if _, err := db.CreateAccount(ctx, "john", keyA, false); err != nil { t.Fatal(err) } if err := db.NewChallenge(ctx, "john", "the-nonce"); err != nil { t.Fatal(err) } if c, err := db.TakeChallenge(ctx, "the-nonce"); err != nil || c.Account != "john" { t.Fatalf("first use: %v %v", c, err) } if _, err := db.TakeChallenge(ctx, "the-nonce"); !errors.Is(err, ErrNotFound) { t.Error("the same nonce was accepted twice") } } func TestExpiredChallengeIsRefused(t *testing.T) { ctx := context.Background() db := open(t) if _, err := db.CreateAccount(ctx, "john", keyA, false); err != nil { t.Fatal(err) } if err := db.NewChallenge(ctx, "john", "stale"); err != nil { t.Fatal(err) } // Hold the clock forward past the window rather than sleeping. real := now now = func() time.Time { return real().Add(ChallengeLife + time.Second) } defer func() { now = real }() if _, err := db.TakeChallenge(ctx, "stale"); !errors.Is(err, ErrNotFound) { t.Error("an expired challenge was accepted") } } // This opens at each older version in turn and upgrades, which a fresh test database never does. func TestAnExistingDatabaseUpgradesToEveryLaterVersion(t *testing.T) { ctx := context.Background() all := migrations t.Cleanup(func() { migrations = all }) for stop := 1; stop <= len(all); stop++ { path := filepath.Join(t.TempDir(), "barerepo.db") // Open as an older barerepo, which knows only the migrations up to that point. migrations = all[:stop] old, err := Open(ctx, "sqlite://"+path) if err != nil { t.Fatalf("opening at version %d: %v", stop, err) } old.Close() // Then upgrade, the way a server does when its binary is replaced. migrations = all db, err := Open(ctx, "sqlite://"+path) if err != nil { t.Fatalf("upgrading from version %d: %v", stop, err) } var have int if err := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&have); err != nil { t.Fatal(err) } if have != len(all) { t.Errorf("upgrading from %d reached version %d, want %d", stop, have, len(all)) } // The columns a later migration adds have to be there, or a query written for them fails. if _, err := db.QueryContext(ctx, `SELECT id, repo, ref, sha, command, image, labels, name, attempts, created_at FROM jobs WHERE state = ?`, JobQueued); err != nil { t.Errorf("upgrading from version %d left the jobs table incomplete: %v", stop, err) } db.Close() } } // A migration already applied must never be edited, or an existing install never gets the change. func TestMigrationsAreAppendOnly(t *testing.T) { // Raising this is deliberate and fine; editing an earlier migration is what this catches. const known = 11 if len(migrations) != known { t.Errorf("there are %d migrations and this test knows %d.\n"+ "if you ADDED one, raise known to %d.\n"+ "if you EDITED an existing one, undo it: an install already past that version "+ "will never run it again, and will break on the first query using the change.", len(migrations), known, len(migrations)) } }