- commit
- 19676c348767318a8bc381294a06ed37d7a39210
- parent
- d23e438e0b038b401edb6b08be69c19df3f31b00
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 5 | ++- |
| M | docs/ | 3 | +- |
| M | docs/ | 9 | +++- |
| M | e2e/ | 4 | +- |
| M | e2e/ | 24 | +++++++++- |
| A | e2e/ | 30 | +++++++++++ |
| A | e2e/ | 30 | +++++++++++ |
| A | internal/ | 62 | ++++++++++++++++++++++++ |
| A | internal/ | 78 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 79 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 64 | +++++++++++++++++------- |
| M | internal/ | 2 | +- |
@@ -434,7 +434,10 @@ The JEV API key is never in the config. `jev.ResolveKey` takes it from
434434 `jev-api-key-command` (split into words and run without a shell, with a435435 timeout, only when a sheet first asks JEV something). The base URL comes436436 from the config or environment only and must be https unless it's437-loopback, so a file next to a sheet can't redirect the key.437+loopback, so a file next to a sheet can't redirect the key. Settings >438+JEV API key checks a new key with `jev.Check`: one fixed, trivial439+question, its error worded for the context line and never holding the440+key.438441 439442 `internal/keyring` is the credential store, chosen for the least440443 dependency weight that is still correct on each OS, with `CGO_ENABLED=0`:@@ -51,7 +51,8 @@ credential store or comes from a command; see [JEV setup](jev.md#setup).
5151 5252 In the app, File > Settings has **Theme** (a picker with live preview),5353 **Open config file** (in your editor, reloading when it closes),54-**Reload config** and **JEV API key**. Reloading applies the options marked54+**Reload config** and **JEV API key** (stored, then checked with one test55+call: see [jev.md](jev.md#setup)). Reloading applies the options marked5556 "File > Settings > Reload config" below; the rest take effect on the next5657 start.5758 @@ -32,8 +32,13 @@ op read op://Private/TypeSafe/credential | 012 config set-key # or pipe it in
3232 ```3333 3434 or in the app, File > Settings > JEV API key, which asks on the context35-line with the key masked and turns JEV on at once. `012 config` says where36-the key would come from, without showing it.35+line with the key masked and turns JEV on at once. It then checks the key36+with one test call, a fixed yes/no question about `2 + 2 = 4` that sends37+none of your data, and says "Key saved and checked" or "Key saved, but the38+check failed" with the reason (such as the service refusing the key). A39+key whose check failed is kept, since the service may only be unreachable40+for now; store the right one the same way. `012 config` says where the41+key would come from, without showing it.3742 3843 012 looks for the key in this order:3944 @@ -88,7 +88,7 @@ func TestSetKeyThenJEV(t *testing.T) {
8888 }8989 9090 // File > Settings > JEV API key takes the key masked, stores it and turns91-// JEV on at once.91+// JEV on at once, after one test call with it.9292 func TestAPIKeyPrompt(t *testing.T) {9393 srv, _ := fakeTypeSafe(t)9494 cfg := t.TempDir()@@ -102,7 +102,7 @@ func TestAPIKeyPrompt(t *testing.T) {
102102 t.Fatal("the key is on screen")103103 }104104 s.keys("<enter>")105- s.waitFor("API key saved in the test credential store")105+ s.waitFor("Key saved and checked; it's in the test credential store")106106 s.waitForLine(gridRow1, " 1 TRUE")107107 data, err := os.ReadFile(filepath.Join(cfg, "012", "test-credential-store"))108108 if err != nil || string(data) != "test-key" {@@ -1,6 +1,12 @@
11 package e2e22 3-import ghostty "go.mitchellh.com/libghostty"3+import (4+ "os"5+ "path/filepath"6+ "testing"7+8+ ghostty "go.mitchellh.com/libghostty"9+)410 511 // The golden screens: every UI state recorded, how to reach it, and which612 // are recorded on a light terminal too. The fixtures they share are in@@ -95,6 +101,22 @@ var screens = []screen{
95101 s.keys("<ctrl+q>")96102 s.waitFor("unsaved changes")97103 }},104+ {name: "save-as-replace", files: func(t *testing.T, dir string) {105+ if err := os.WriteFile(filepath.Join(dir, "budget.012"), []byte("A1 old\n"), 0o644); err != nil {106+ t.Fatal(err)107+ }108+ }, setup: func(s *session) {109+ budget(s)110+ s.keys("<ctrl+s>", "budget", "<enter>")111+ s.waitFor("budget.012 exists.")112+ }},113+ // Nothing listens on port 1: the key is stored, and its check fails.114+ {name: "api-key-check-failed", opts: options{env: []string{"TYPESAFE_BASE_URL=http://127.0.0.1:1"}}, setup: func(s *session) {115+ s.keys("<alt+f>", "<up>", "<up>", "<right>", "<down>", "<down>", "<down>")116+ s.waitFor("Store the TypeSafe API key")117+ s.keys("<enter>", "test-key", "<enter>")118+ s.waitFor("Key saved, but the check failed")119+ }},98120 {name: "prompt-width", setup: func(s *session) {99121 budget(s)100122 s.keys("<ctrl+home>", "<alt+o>", "c", "<enter>", "<right>", "<right>", "<right>")@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">READY</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A1</span><span style="background:var(--vt-palette-8)"> </span>3+<span style="color:var(--vt-palette-3)">Key</span> <span style="color:var(--vt-palette-3)">saved,</span> <span style="color:var(--vt-palette-3)">but</span> <span style="color:var(--vt-palette-3)">the</span> <span style="color:var(--vt-palette-3)">check</span> <span style="color:var(--vt-palette-3)">failed:</span> <span style="color:var(--vt-palette-3)">couldn't</span> <span style="color:var(--vt-palette-3)">reach</span> <span style="color:var(--vt-palette-3)">the</span> <span style="color:var(--vt-palette-3)">service:</span> <span style="color:var(--vt-palette-3)">connection</span> <span style="color:var(--vt-palette-3)">refused</span>4+<span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">A</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">1</span><span style="background:var(--vt-palette-6)"> </span>6+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span>8+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span>9+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span>10+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span>11+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">7</span><span style="background:var(--vt-palette-8)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,30 @@
1+<pre class="screen"> <span style="text-decoration:underline">F</span>ile <span style="text-decoration:underline">E</span>dit <span style="text-decoration:underline">V</span>iew <span style="text-decoration:underline">I</span>nsert F<span style="text-decoration:underline">o</span>rmat <span style="text-decoration:underline">D</span>ata <span style="text-decoration:underline">H</span>elp <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">MENU</span><span style="background:var(--vt-palette-6)"> </span>2+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">B7</span><span style="background:var(--vt-palette-8)"> </span> =SUM(B3:B5)3+<span style="color:var(--vt-palette-3)">budget.012</span> <span style="color:var(--vt-palette-3)">exists.</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Enter</span><span style="background:var(--vt-palette-8)"> </span> Replace <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Esc</span><span style="background:var(--vt-palette-8)"> </span> Cancel4+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">A</span><span style="background:var(--vt-palette-8)"> </span><span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">B</span><span style="background:var(--vt-palette-6)"> </span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">C</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">D</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">E</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">G</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">H</span><span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">I</span><span style="background:var(--vt-palette-8)"> </span>5+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">1</span><span style="background:var(--vt-palette-8)"> </span> Household budget 20266+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">2</span><span style="background:var(--vt-palette-8)"> </span>7+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">3</span><span style="background:var(--vt-palette-8)"> </span> Rent 14508+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">4</span><span style="background:var(--vt-palette-8)"> </span> Groceries 612.49+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">5</span><span style="background:var(--vt-palette-8)"> </span> Transit 9610+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">6</span><span style="background:var(--vt-palette-8)"> </span> Savings r <span style="color:var(--vt-palette-1);text-decoration:underline wavy var(--vt-palette-1)">#DIV/0!</span>11+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">7</span><span style="background:var(--vt-palette-6)"> </span> Total <span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6)">2158.4</span><span style="background:var(--vt-palette-6)"> </span>12+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">8</span><span style="background:var(--vt-palette-8)"> </span>13+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">9</span><span style="background:var(--vt-palette-8)"> </span>14+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">10</span><span style="background:var(--vt-palette-8)"> </span>15+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">11</span><span style="background:var(--vt-palette-8)"> </span>16+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">12</span><span style="background:var(--vt-palette-8)"> </span>17+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">13</span><span style="background:var(--vt-palette-8)"> </span>18+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">14</span><span style="background:var(--vt-palette-8)"> </span>19+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">15</span><span style="background:var(--vt-palette-8)"> </span>20+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">16</span><span style="background:var(--vt-palette-8)"> </span>21+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">17</span><span style="background:var(--vt-palette-8)"> </span>22+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">18</span><span style="background:var(--vt-palette-8)"> </span>23+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">19</span><span style="background:var(--vt-palette-8)"> </span>24+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">20</span><span style="background:var(--vt-palette-8)"> </span>25+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">21</span><span style="background:var(--vt-palette-8)"> </span>26+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">22</span><span style="background:var(--vt-palette-8)"> </span>27+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">23</span><span style="background:var(--vt-palette-8)"> </span>28+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">24</span><span style="background:var(--vt-palette-8)"> </span>29+<span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">25</span><span style="background:var(--vt-palette-8)"> </span>30+<span style="background:var(--vt-palette-6)"> </span><span style="color:var(--vt-palette-0);background:var(--vt-palette-6);font-weight:bold">Sheet1</span><span style="background:var(--vt-palette-6)"> </span> <span style="color:var(--vt-palette-8)">+</span> <span style="color:var(--vt-palette-8)">│</span> untitled <span style="color:var(--vt-palette-8)">modified</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+K</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">search</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">Ctrl+/</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">shortcuts</span> <span style="background:var(--vt-palette-8)"> </span><span style="color:var(--vt-palette-15);background:var(--vt-palette-8)">F10</span><span style="background:var(--vt-palette-8)"> </span> <span style="color:var(--vt-palette-8)">menu</span></pre>\ No newline at end of file@@ -0,0 +1,62 @@
1+package jev2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "net"8+ "strings"9+ "syscall"10+11+ typesafe "github.com/FelineStateMachine/typesafe-go"12+)13+14+// CheckQuestion is the question Check asks: fixed, trivial and about15+// nothing the user owns, so checking a key costs one small request and16+// sends no data.17+const CheckQuestion = "Is this arithmetic correct?"18+19+// checkState is the value CheckQuestion is asked about.20+const checkState = "2 + 2 = 4"21+22+// Check asks the service CheckQuestion with client, to see that its key23+// works. The error says why not, in words for the context line, and24+// never holds the key.25+func Check(ctx context.Context, client Client) error {26+ resp, err := client.SystemOne(ctx, typesafe.SystemOneRequest{27+ State: map[string]any{"value": checkState},28+ Questions: map[string]typesafe.Question{"answer": typesafe.NoulQuestion{Instructions: CheckQuestion}},29+ })30+ if err != nil {31+ return checkError(err)32+ }33+ if _, err := resp.Noul("answer"); err != nil {34+ return checkError(err)35+ }36+ return nil37+}38+39+// checkError words err for the context line.40+func checkError(err error) error {41+ var (42+ api *typesafe.APIError43+ errno syscall.Errno44+ dns *net.DNSError45+ op *net.OpError46+ )47+ switch {48+ case errors.As(err, &api) && (api.StatusCode == 401 || api.StatusCode == 403):49+ return fmt.Errorf("the service refused the key (HTTP %d)", api.StatusCode)50+ case errors.As(err, &api):51+ return fmt.Errorf("the service answered HTTP %d", api.StatusCode)52+ case errors.Is(err, context.DeadlineExceeded):53+ return errors.New("the service didn't answer in time")54+ case errors.As(err, &errno):55+ return fmt.Errorf("couldn't reach the service: %v", errno)56+ case errors.As(err, &dns):57+ return fmt.Errorf("couldn't reach the service: no such host %s", dns.Name)58+ case errors.As(err, &op):59+ return fmt.Errorf("couldn't reach the service: %v", op.Err)60+ }61+ return errors.New(strings.TrimPrefix(err.Error(), "typesafe: "))62+}@@ -0,0 +1,78 @@
1+package jev2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "sync/atomic"10+ "testing"11+)12+13+// fakeTypeSafe stands in for the TypeSafe API, accepting only key and14+// answering yes/no questions with 0.9. It counts requests and remembers15+// the last question.16+func fakeTypeSafe(t *testing.T, key string) (*httptest.Server, *atomic.Int32, *atomic.Value) {17+ t.Helper()18+ var n atomic.Int3219+ var last atomic.Value20+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {21+ n.Add(1)22+ if r.Header.Get("Authorization") != "Bearer "+key {23+ w.Header().Set("Content-Type", "application/json")24+ w.WriteHeader(http.StatusUnauthorized)25+ w.Write([]byte(`{"error":{"message":"invalid api key"}}`))26+ return27+ }28+ var req struct {29+ Questions map[string]struct {30+ Type string `json:"type"`31+ Instructions string `json:"instructions"`32+ } `json:"questions"`33+ }34+ json.NewDecoder(r.Body).Decode(&req)35+ last.Store(req.Questions["answer"].Instructions)36+ json.NewEncoder(w).Encode(map[string]any{37+ "model": "jev-latest",38+ "answers": map[string]any{"answer": map[string]any{"type": "noul", "noul": 0.9}},39+ "usage": map[string]int{"input_tokens": 1, "output_tokens": 1},40+ })41+ }))42+ t.Cleanup(srv.Close)43+ return srv, &n, &last44+}45+46+// Check asks one fixed question; a key the service refuses fails with47+// a reason that doesn't hold the key.48+func TestCheck(t *testing.T) {49+ srv, n, last := fakeTypeSafe(t, "good-key")50+ c, err := NewClient(Config{APIKey: "good-key", BaseURL: srv.URL})51+ if err != nil {52+ t.Fatal(err)53+ }54+ if err := Check(context.Background(), c); err != nil {55+ t.Fatalf("good key: %v", err)56+ }57+ if n.Load() != 1 || last.Load() != CheckQuestion {58+ t.Errorf("%d requests, question %q", n.Load(), last.Load())59+ }60+61+ const bad = "bad-key-12345"62+ c, _ = NewClient(Config{APIKey: bad, BaseURL: srv.URL})63+ err = Check(context.Background(), c)64+ if err == nil || !strings.Contains(err.Error(), "refused the key (HTTP 401)") || strings.Contains(err.Error(), bad) {65+ t.Errorf("bad key: %v", err)66+ }67+}68+69+// A service that can't be reached is said in few words.70+func TestCheckUnreachable(t *testing.T) {71+ c, err := NewClient(Config{APIKey: "k", BaseURL: "http://127.0.0.1:1"})72+ if err != nil {73+ t.Fatal(err)74+ }75+ if err := Check(context.Background(), c); err == nil || err.Error() != "couldn't reach the service: connection refused" {76+ t.Errorf("unreachable: %v", err)77+ }78+}@@ -0,0 +1,79 @@
1+package ui2+3+import (4+ "encoding/json"5+ "net/http"6+ "net/http/httptest"7+ "os"8+ "path/filepath"9+ "strings"10+ "sync/atomic"11+ "testing"12+13+ tea "charm.land/bubbletea/v2"14+15+ "github.com/FelineStateMachine/012/internal/config"16+ "github.com/FelineStateMachine/012/internal/jev"17+ "github.com/FelineStateMachine/012/internal/keyring"18+)19+20+// fakeTypeSafe stands in for the TypeSafe API on this machine: it21+// accepts only goodKey and answers yes/no questions with 0.9.22+func fakeTypeSafe(t *testing.T, goodKey string) (*httptest.Server, *atomic.Int32) {23+ t.Helper()24+ var n atomic.Int3225+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {26+ n.Add(1)27+ if r.URL.Path != "/v1/systemone" || r.Header.Get("Authorization") != "Bearer "+goodKey {28+ w.Header().Set("Content-Type", "application/json")29+ w.WriteHeader(http.StatusUnauthorized)30+ w.Write([]byte(`{"error":{"message":"invalid api key"}}`))31+ return32+ }33+ json.NewEncoder(w).Encode(map[string]any{34+ "model": "jev-latest",35+ "answers": map[string]any{"answer": map[string]any{"type": "noul", "noul": 0.9}},36+ "usage": map[string]int{"input_tokens": 1, "output_tokens": 1},37+ })38+ }))39+ t.Cleanup(srv.Close)40+ return srv, &n41+}42+43+// Storing a key makes one test call with it and says how it went,44+// without ever showing the key; a refused key is still stored.45+func TestAPIKeyChecked(t *testing.T) {46+ const good, bad = "ts-good-4242", "ts-bad-1313"47+ srv, requests := fakeTypeSafe(t, good)48+ path := filepath.Join(t.TempDir(), "config")49+ os.WriteFile(path, nil, 0o600)50+ store := &keyring.Memory{}51+ m := newModel()52+ m.Update(tea.WindowSizeMsg{Width: 100, Height: 20})53+ m.Configure(Settings{Config: config.Load(path, func(string) string { return "" }, nil), Keys: store,54+ Connect: func(key string) (jev.Client, error) {55+ return jev.NewClient(jev.Config{APIKey: key, BaseURL: srv.URL})56+ }})57+58+ m.runCommand("settings.jev_key")59+ press(t, m, good, "<enter>")60+ if got := line(m, contextLine); !strings.Contains(got, "Key saved and checked") || requests.Load() != 1 {61+ t.Fatalf("good key: %q after %d requests", got, requests.Load())62+ }63+64+ m.runCommand("settings.jev_key")65+ press(t, m, bad, "<enter>")66+ got := line(m, contextLine)67+ if !strings.Contains(got, "Key saved, but the check failed: the service refused the key (HTTP 401)") {68+ t.Errorf("bad key: %q", got)69+ }70+ if strings.Contains(screen(m), bad) || strings.Contains(screen(m), good) {71+ t.Error("a key is on the screen")72+ }73+ if k, _ := store.Get(t.Context()); k != bad || m.jev == nil {74+ t.Errorf("the refused key wasn't kept: %q, JEV on %v", k, m.jev != nil)75+ }76+ if n := requests.Load(); n != 2 {77+ t.Errorf("%d requests; want one per key", n)78+ }79+}@@ -136,13 +136,19 @@ func (m *Model) reloadConfig() {
136136 m.note = "Config reloaded from " + config.Tilde(c.Path)137137 }138138 139-// keySavedMsg reports storing the API key, and the client made with it.139+// keySavedMsg reports storing the API key, the client made with it, and140+// how the test call with it went.140141 type keySavedMsg struct {141142 client jev.Client142- err error143+ err error // storing it or connecting failed144+ check string // why the test call failed, "" when it worked143145 }144146 145-// askKey asks for the API key on the context line, masked, and stores it.147+// keyCheckTimeout bounds the test call made with a new key.148+const keyCheckTimeout = 15 * time.Second149+150+// askKey asks for the API key on the context line, masked, stores it,151+// and checks it with one small test call (jev.Check).146152 func (m *Model) askKey() tea.Cmd {147153 m.openPrompt(&prompt{kind: promptText, label: "TypeSafe API key:", indicator: "KEY", secret: true,148154 onText: func(m *Model, key string) tea.Cmd {@@ -150,25 +156,41 @@ func (m *Model) askKey() tea.Cmd {
150156 m.note = "No key entered; nothing changed"151157 return nil152158 }153- m.note = "Saving the key in the " + m.prefs.Keys.Name() + "…"154- store, connect := m.prefs.Keys, m.prefs.Connect155- return func() tea.Msg {156- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)157- defer cancel()158- if err := store.Set(ctx, key); err != nil {159- return keySavedMsg{err: err}160- }161- if connect == nil {162- return keySavedMsg{err: errors.New("JEV isn't available in this session")}163- }164- client, err := connect(key)165- return keySavedMsg{client: client, err: err}166- }159+ m.note = "Saving the key in the " + m.prefs.Keys.Name() + " and checking it…"160+ return saveKeyCmd(m.prefs.Keys, m.prefs.Connect, key)167161 }}, "")168162 return nil169163 }170164 171-// keySaved turns JEV on with the new key.165+// saveKeyCmd stores key, connects with it and makes the test call.166+func saveKeyCmd(store keyring.Store, connect func(string) (jev.Client, error), key string) tea.Cmd {167+ return func() tea.Msg {168+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)169+ defer cancel()170+ if err := store.Set(ctx, key); err != nil {171+ return keySavedMsg{err: err}172+ }173+ if connect == nil {174+ return keySavedMsg{err: errors.New("JEV isn't available in this session")}175+ }176+ client, err := connect(key)177+ if err != nil {178+ return keySavedMsg{err: err}179+ }180+ cctx, ccancel := context.WithTimeout(context.Background(), keyCheckTimeout)181+ defer ccancel()182+ msg := keySavedMsg{client: client}183+ if err := jev.Check(cctx, client); err != nil {184+ // Whatever the service said, it never gets to show the key.185+ msg.check = strings.ReplaceAll(err.Error(), key, "…")186+ }187+ return msg188+ }189+}190+191+// keySaved turns JEV on with the new key and says how its check went.192+// A key whose check failed is kept and used: the service may only be193+// unreachable for now.172194 func (m *Model) keySaved(msg keySavedMsg) {173195 if msg.err != nil {174196 m.fail("Couldn't save the API key: " + msg.err.Error())@@ -180,7 +202,11 @@ func (m *Model) keySaved(msg keySavedMsg) {
180202 m.jev.client = msg.client181203 }182204 m.sheet.RecalcVolatile()183- m.note = fmt.Sprintf("API key saved in the %s; JEV functions are on", m.prefs.Keys.Name())205+ if msg.check != "" {206+ m.note = m.th.Warning.Render("Key saved, but the check failed: " + msg.check)207+ return208+ }209+ m.note = fmt.Sprintf("Key saved and checked; it's in the %s and JEV functions are on", m.prefs.Keys.Name())184210 }185211 186212 // handlePrefs handles the messages settings get: the terminal's@@ -173,7 +173,7 @@ func TestAPIKeyPrompt(t *testing.T) {
173173 if got, _ := store.Get(context.Background()); got != key {174174 t.Errorf("stored %q", got)175175 }176- if m.jev == nil || !strings.Contains(line(m, contextLine), "API key saved in the test store") {176+ if m.jev == nil || !strings.Contains(line(m, contextLine), "Key saved and checked; it's in the test store") {177177 t.Errorf("JEV not on: %q", line(m, contextLine))178178 }179179 if strings.Contains(screen(m), key) || m.line.Text() != "" {