package store import ( "context" "encoding/json" "path/filepath" "strings" "testing" "github.com/kristianwind/verdande/internal/secret" ) // The whole point of the exercise: what is written must not be readable in the // file that a backup copies. func TestATokenIsNotStoredInTheClear(t *testing.T) { db, userID := sealedStore(t) ctx := context.Background() const token = "1//0-a-real-looking-refresh-token" if err := db.SetUserSettings(ctx, userID, "gmail", map[string]any{ "refresh_token": token, "label": "Verdande", }); err != nil { t.Fatal(err) } var raw string if err := db.QueryRowContext(ctx, `SELECT values_json FROM user_settings WHERE user_id = ?`, userID).Scan(&raw); err != nil { t.Fatal(err) } if strings.Contains(raw, token) { t.Fatalf("the token is in the row as written: %s", raw) } // The rest stays readable, which is the reason for naming the secret fields // rather than sealing the whole blob. if !strings.Contains(raw, "Verdande") { t.Errorf("the label was sealed too: %s", raw) } back, err := db.UserSettings(ctx, userID, "gmail") if err != nil { t.Fatal(err) } if back["refresh_token"] != token { t.Errorf("got %q back", back["refresh_token"]) } } // The migration that matters. A row written before there was a key must still be // readable, and must put itself away sealed the next time it is written — nobody // should have to reconnect a mailbox because the server learned to encrypt. func TestATokenWrittenBeforeTheKeyStillWorks(t *testing.T) { db, userID := sealedStore(t) ctx := context.Background() const token = "1//0-written-when-there-was-no-key" plain, _ := json.Marshal(map[string]any{"refresh_token": token}) if _, err := db.ExecContext(ctx, ` INSERT INTO user_settings (user_id, scope, values_json, updated_at) VALUES (?, 'gmail', ?, 0)`, userID, string(plain)); err != nil { t.Fatal(err) } values, err := db.UserSettings(ctx, userID, "gmail") if err != nil { t.Fatalf("a row from before the key could not be read: %v", err) } if values["refresh_token"] != token { t.Fatalf("got %q, want the plain token back", values["refresh_token"]) } // Written back — and now sealed, without anything having asked for it. if err := db.SetUserSettings(ctx, userID, "gmail", values); err != nil { t.Fatal(err) } var raw string if err := db.QueryRowContext(ctx, `SELECT values_json FROM user_settings WHERE user_id = ?`, userID).Scan(&raw); err != nil { t.Fatal(err) } if strings.Contains(raw, token) { t.Error("the row is still in the clear after being written back") } } // The sweep finds people by the presence of a mailbox, so it must keep working // now that the credential in it is ciphertext. func TestTheSweepStillFindsAConnectedMailbox(t *testing.T) { db, userID := sealedStore(t) ctx := context.Background() if err := db.SaveMailbox(ctx, &Mailbox{ UserID: userID, Kind: "gmail", RefreshToken: "1//0-token", }); err != nil { t.Fatal(err) } users, err := db.UsersWithMailboxes(ctx) if err != nil { t.Fatal(err) } if len(users) != 1 { t.Fatalf("the sweep found %d people with mailboxes, want 1", len(users)) } } func sealedStore(t *testing.T) (*DB, string) { t.Helper() db, err := Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) box, err := secret.Open("", t.TempDir()) if err != nil { t.Fatal(err) } db.UseSecrets(box) user := &User{Email: "kristian@example.dk", Name: "Kristian"} if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil { t.Fatal(err) } return db, user.ID } // Three integrations, one person, and none of them may delete the others. // // `user_settings` had `user_id` as its whole primary key and an upsert that // rewrote `scope` on conflict, so every person had exactly one settings row and // the last write won the lot: saving an AI provider deleted the Gmail connection, // and connecting a calendar deleted the AI provider. The write reported success // each time, which is why it was reported as "the AI settings are not saved" // rather than as "connecting the calendar deleted them". // // Written against the store rather than the handlers, because the shape was the // bug: every caller was correct on its own. func TestOnePersonCanHaveSettingsForMoreThanOneThing(t *testing.T) { db, userID := sealedStore(t) ctx := context.Background() want := map[string]map[string]any{ "ai": {"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-ant-xxx"}, "gmail": {"refresh_token": "1//0-gmail", "label": "Verdande"}, "calendar": {"refresh_token": "1//0-calendar", "calendar_id": "primary"}, } // In this order on purpose: the AI settings are written first and read last, // so a scope that steals the row is caught by the one written earliest. for _, scope := range []string{"ai", "gmail", "calendar"} { if err := db.SetUserSettings(ctx, userID, scope, want[scope]); err != nil { t.Fatalf("save %s: %v", scope, err) } } for _, scope := range []string{"ai", "gmail", "calendar"} { got, err := db.UserSettings(ctx, userID, scope) if err != nil { t.Fatalf("read %s: %v", scope, err) } for key, value := range want[scope] { if got[key] != value { t.Errorf("%s.%s is %v, want %v", scope, key, got[key], value) } } } // And a second write to one scope updates it rather than adding another row — // the upsert still has to upsert. if err := db.SetUserSettings(ctx, userID, "ai", map[string]any{"provider": "openai"}); err != nil { t.Fatal(err) } var rows int if err := db.QueryRowContext(ctx, `SELECT count(*) FROM user_settings WHERE user_id = ?`, userID).Scan(&rows); err != nil { t.Fatal(err) } if rows != 3 { t.Errorf("the person has %d settings rows, want 3", rows) } back, err := db.UserSettings(ctx, userID, "ai") if err != nil { t.Fatal(err) } if back["provider"] != "openai" { t.Errorf("the second write did not land: %v", back["provider"]) } } // The upgrade path, which is the one nobody runs: an existing database with a row // in the old shape, opened by a binary that carries 0025. // // Every other test here starts empty and runs all the migrations in order, which // exercises the migration's SQL but not its *situation*. The situation is a table // that already holds somebody's settings, and the question is whether they come // through it and whether a second scope can be written afterwards. func TestSettingsSurviveTheUpgradeToPerScopeRows(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "test.db") db, err := Open(path) if err != nil { t.Fatal(err) } ctx := context.Background() user := &User{Email: "kristian@example.dk", Name: "Kristian"} if err := db.CreateUser(ctx, user, "Indbakke"); err != nil { t.Fatal(err) } // Wind the database back to how v0.33.3 left it: user_id as the whole primary // key, and 0025 not yet applied. for _, stmt := range []string{ `PRAGMA foreign_keys = OFF`, `CREATE TABLE us_old ( user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE, scope TEXT NOT NULL DEFAULT 'general', values_json TEXT NOT NULL DEFAULT '{}', updated_at INTEGER NOT NULL )`, `INSERT INTO us_old (user_id, scope, values_json, updated_at) SELECT user_id, scope, values_json, updated_at FROM user_settings`, `DROP TABLE user_settings`, `ALTER TABLE us_old RENAME TO user_settings`, `DELETE FROM schema_migrations WHERE name = '0025_user_settings_per_scope.sql'`, `PRAGMA foreign_keys = ON`, } { if _, err := db.ExecContext(ctx, stmt); err != nil { t.Fatalf("rewind (%s): %v", stmt, err) } } // A Gmail connection, written the way the old upsert wrote one. if _, err := db.ExecContext(ctx, ` INSERT INTO user_settings (user_id, scope, values_json, updated_at) VALUES (?, 'gmail', '{"label":"Verdande"}', 0)`, user.ID); err != nil { t.Fatal(err) } if err := db.Close(); err != nil { t.Fatal(err) } // And now the new binary opens it. db, err = Open(path) if err != nil { t.Fatalf("the upgrade refused to open the database: %v", err) } defer db.Close() gmail, err := db.UserSettings(ctx, user.ID, "gmail") if err != nil { t.Fatal(err) } if gmail["label"] != "Verdande" { t.Errorf("the Gmail settings did not survive the migration: %v", gmail) } // The thing that was reported: set up the AI provider, and have it still be // there afterwards. if err := db.SetUserSettings(ctx, user.ID, "ai", map[string]any{ "provider": "anthropic", "model": "claude-sonnet-5", }); err != nil { t.Fatalf("saving the AI provider after the upgrade: %v", err) } ai, err := db.UserSettings(ctx, user.ID, "ai") if err != nil { t.Fatal(err) } if ai["provider"] != "anthropic" { t.Errorf("the AI provider is %v after the upgrade, want anthropic", ai["provider"]) } again, err := db.UserSettings(ctx, user.ID, "gmail") if err != nil { t.Fatal(err) } if again["label"] != "Verdande" { t.Errorf("saving the AI provider took the Gmail settings: %v", again) } } // Det, en note siger, må ikke kunne læses i filen, som en sikkerhedskopi kopierer. // // Grunden er den samme som for postkassers kodeord: en sikkerhedskopi kan hentes // gennem fladen. Noter er dér, folk skriver kodeord og API-nøgler ned — det ved vi, // fordi der ligger sådan nogle i den installation, det her blev bygget til. func TestANoteIsNotStoredInTheClear(t *testing.T) { db, userID := sealedStore(t) ctx := context.Background() const secretLine = "gamerule keepInventory true" n := &Note{CreatedBy: userID, Body: "Minecraft Bedrock\n\n" + secretLine} if err := db.SaveNote(ctx, n); err != nil { t.Fatal(err) } var title, body string if err := db.QueryRowContext(ctx, `SELECT title, body FROM notes WHERE id = ?`, n.ID).Scan(&title, &body); err != nil { t.Fatal(err) } if strings.Contains(body, secretLine) { t.Errorf("kroppen står i klartekst i rækken: %s", body) } // Og titlen med. Titlen *er* første linje, så en forseglet krop ved siden af en // læselig titel ville efterlade begyndelsen af hver eneste note i klartekst. if strings.Contains(title, "Minecraft") { t.Errorf("titlen står i klartekst i rækken: %s", title) } // Kalderens egen note er urørt: den har den i hånden og læser videre i den. if n.Body != "Minecraft Bedrock\n\n"+secretLine { t.Errorf("gemningen ændrede kalderens note: %q", n.Body) } // Læst tilbage er den sig selv igen. back, err := db.Note(ctx, n.ID) if err != nil { t.Fatal(err) } if back.Body != n.Body || back.Title != "Minecraft Bedrock" { t.Errorf("noten kom anderledes tilbage: %+v", back) } // Og søgningen virker stadig — den åbner teksten nu, hvor den før spurgte et // indeks, der var en klartekstkopi af den. found, err := db.SearchNotes(ctx, "keep inventory", 10) if err != nil { t.Fatal(err) } if len(found) != 1 { t.Fatalf("søgningen fandt %d noter i en forseglet samling", len(found)) } } // Noter skrevet før nøglen fandtes skal lukkes, ikke efterlades. // // Uden en bagudfyldning ville kroppene blive forseglet efterhånden som de bliver // *skrevet* — og et arkiv på tolv hundrede importerede noter, som ingen redigerer // igen, ville aldrig blive det. Det er præcis dét arkiv, der er værd at forsegle. func TestOldNotesAreSealedOnStartup(t *testing.T) { db, userID := sealedStore(t) ctx := context.Background() // Skrevet uden om Go-laget, som en note fra før 0027 ligger. if _, err := db.ExecContext(ctx, ` INSERT INTO notes (id, title, body, created_by, created_at, updated_at) VALUES ('n-gammel', 'Immich DB', 'Immich DB\nkodeord: hemmeligt', ?, 100, 200)`, userID); err != nil { t.Fatal(err) } sealed, err := db.SealNotes(ctx) if err != nil { t.Fatal(err) } if sealed != 1 { t.Fatalf("%d noter blev lukket, want 1", sealed) } var body string var updated int64 if err := db.QueryRowContext(ctx, `SELECT body, updated_at FROM notes WHERE id = 'n-gammel'`).Scan(&body, &updated); err != nil { t.Fatal(err) } if strings.Contains(body, "hemmeligt") { t.Errorf("den gamle note ligger stadig åben: %s", body) } // `updated_at` er ikke rørt. Det betyder "hvornår skrev dette program noten // sidst", og at lukke en konvolut om den er ikke at skrive den — en // bagudfyldning, der stemplede tolv hundrede noter med i aften, ville kaste den // rækkefølge væk, importen brugte en dag på at hente hjem. if updated != 200 { t.Errorf("bagudfyldningen flyttede updated_at til %d", updated) } // Og den kan læses. back, err := db.Note(ctx, "n-gammel") if err != nil { t.Fatal(err) } if back == nil || !strings.Contains(back.Body, "hemmeligt") { t.Fatalf("den gamle note kunne ikke læses tilbage: %+v", back) } // Kørt igen gør den ingenting: en note, der allerede er lukket, kendes på sit // præfiks. again, err := db.SealNotes(ctx) if err != nil { t.Fatal(err) } if again != 0 { t.Errorf("anden kørsel lukkede %d noter om igen", again) } }