Tasks demo TestClient suite

Drives the Tasks app through the in-process TestClient.

Path
gebweb/examples/tasks/main_test.gb
Category
Tasks

Covers the login flow, owner-scoped CRUD, cross-user isolation,

admin role gating, and multipart file upload.

Source

import test;
import gebweb;
import json;
import tasksapp;

const BOUNDARY = "----tasks-test-boundary";

func bearer(string token): dict<string, any> {
    return {"Authorization": "Bearer " + token};
}

func multipartFile(string fieldName, string filename, string contentType, string body): string {
    return "------tasks-test-boundary\r\n"
        + "Content-Disposition: form-data; name=\"" + fieldName
        + "\"; filename=\"" + filename + "\"\r\n"
        + "Content-Type: " + contentType + "\r\n\r\n"
        + body + "\r\n"
        + "------tasks-test-boundary--\r\n";
}

func multipartHeaders(string token): dict<string, any> {
    return {
        "Authorization": "Bearer " + token,
        "Content-Type": "multipart/form-data; boundary=----tasks-test-boundary",
    };
}

class LoginTest extends test.Test {
    gebweb.TestClient client;

    func setUp(): void {
        this.client = gebweb.TestClient(tasksapp.build());
    }

    @test
    func loginAsKnownUserMintsToken(): void {
        let r = this.client.post("/login", {"username": "ada"});
        r.assertStatus(200);
        this.assertTrue(!(r.json()["token"] as string).isEmpty());
        this.assertEquals(3600, r.json()["expires_in"] as int);
    }

    @test
    func loginAsUnknownUserIs401(): void {
        let r = this.client.post("/login", {"username": "nobody"});
        r.assertStatus(401);
    }

    @test
    func tasksRequireAuth(): void {
        let r = this.client.get("/tasks");
        r.assertStatus(401);
    }

    @test
    func invalidBearerIs401(): void {
        let r = this.client.request("GET", "/tasks", null, bearer("garbage"));
        r.assertStatus(401);
    }
}

class TaskCrudTest extends test.Test {
    gebweb.TestClient client;
    string adaToken;
    string carlaToken;

    func setUp(): void {
        this.client = gebweb.TestClient(tasksapp.build());
        this.adaToken = (this.client.post("/login", {"username": "ada"})
            .json()["token"]) as string;
        this.carlaToken = (this.client.post("/login", {"username": "carla"})
            .json()["token"]) as string;
    }

    @test
    func createAndListOwnTasks(): void {
        let a = this.client.request("POST", "/tasks",
            {"title": "write docs"}, bearer(this.adaToken));
        a.assertStatus(200);
        let id = a.json()["id"] as string;
        this.assertEquals("write docs", a.json()["title"] as string);
        this.assertEquals(false, a.json()["done"]);

        let list = this.client.request("GET", "/tasks", null, bearer(this.adaToken));
        list.assertStatus(200);
        this.assertEquals(1, list.json()["total"] as int);
        let items = list.json()["items"] as list<any>;
        this.assertEquals(id, (items[0] as tasksapp.Task).id);
    }

    @test
    func anotherUserCannotSeeMyTask(): void {
        let r = this.client.request("POST", "/tasks",
            {"title": "ada's task"}, bearer(this.adaToken));
        let id = r.json()["id"] as string;

        /* Carla's list excludes Ada's task entirely. */
        let carlaList = this.client.request("GET", "/tasks", null,
            bearer(this.carlaToken));
        this.assertEquals(0, carlaList.json()["total"] as int);

        /* And cannot read Ada's task by id. */
        let carlaRead = this.client.request("GET", "/tasks/" + id, null,
            bearer(this.carlaToken));
        carlaRead.assertStatus(404);
    }

    @test
    func patchUpdatesFields(): void {
        let created = this.client.request("POST", "/tasks",
            {"title": "draft"}, bearer(this.adaToken));
        let id = created.json()["id"] as string;

        let patched = this.client.request("PATCH", "/tasks/" + id,
            {"title": "final", "done": true}, bearer(this.adaToken));
        patched.assertStatus(200);
        this.assertEquals("final", patched.json()["title"] as string);
        this.assertEquals(true, patched.json()["done"]);
    }

    @test
    func deleteRemovesTask(): void {
        let created = this.client.request("POST", "/tasks",
            {"title": "doomed"}, bearer(this.adaToken));
        let id = created.json()["id"] as string;

        let deleted = this.client.request("DELETE", "/tasks/" + id, null,
            bearer(this.adaToken));
        deleted.assertStatus(204);

        let read = this.client.request("GET", "/tasks/" + id, null,
            bearer(this.adaToken));
        read.assertStatus(404);
    }

    @test
    func cannotDeleteAnotherUsersTask(): void {
        let created = this.client.request("POST", "/tasks",
            {"title": "ada's"}, bearer(this.adaToken));
        let id = created.json()["id"] as string;

        let carlaDelete = this.client.request("DELETE", "/tasks/" + id, null,
            bearer(this.carlaToken));
        carlaDelete.assertStatus(404);
    }
}

class AdminTest extends test.Test {
    gebweb.TestClient client;
    string adaToken;
    string carlaToken;

    func setUp(): void {
        this.client = gebweb.TestClient(tasksapp.build());
        this.adaToken = (this.client.post("/login", {"username": "ada"})
            .json()["token"]) as string;
        this.carlaToken = (this.client.post("/login", {"username": "carla"})
            .json()["token"]) as string;

        /* Seed: one task from each user. */
        this.client.request("POST", "/tasks", {"title": "ada-1"},
            bearer(this.adaToken));
        this.client.request("POST", "/tasks", {"title": "carla-1"},
            bearer(this.carlaToken));
    }

    @test
    func adminSeesAllTasksAcrossUsers(): void {
        let r = this.client.request("GET", "/admin/tasks", null,
            bearer(this.carlaToken));
        r.assertStatus(200);
        this.assertEquals(2, r.json()["total"] as int);
    }

    @test
    func nonAdminGetsForbidden(): void {
        let r = this.client.request("GET", "/admin/tasks", null,
            bearer(this.adaToken));
        r.assertStatus(403);
    }

    @test
    func adminCanForceDeleteAnyTask(): void {
        let list = this.client.request("GET", "/admin/tasks", null,
            bearer(this.carlaToken));
        let items = list.json()["items"] as list<any>;
        let target = items[0] as tasksapp.Task;
        let id = target.id;

        let removed = this.client.request("DELETE", "/admin/tasks/" + id, null,
            bearer(this.carlaToken));
        removed.assertStatus(204);

        let after = this.client.request("GET", "/admin/tasks", null,
            bearer(this.carlaToken));
        this.assertEquals(1, after.json()["total"] as int);
    }
}

class AttachmentTest extends test.Test {
    gebweb.TestClient client;
    string adaToken;
    string carlaToken;
    string taskId;

    func setUp(): void {
        this.client = gebweb.TestClient(tasksapp.build());
        this.adaToken = (this.client.post("/login", {"username": "ada"})
            .json()["token"]) as string;
        this.carlaToken = (this.client.post("/login", {"username": "carla"})
            .json()["token"]) as string;
        let created = this.client.request("POST", "/tasks",
            {"title": "with attachment"}, bearer(this.adaToken));
        this.taskId = created.json()["id"] as string;
    }

    @test
    func uploadAttachmentToOwnTask(): void {
        let body = multipartFile("file", "notes.md", "text/markdown",
            "# Hello\nthis is a note");
        let r = this.client.request("POST",
            "/tasks/" + this.taskId + "/attachments", body,
            multipartHeaders(this.adaToken));
        r.assertStatus(200);
        this.assertEquals("notes.md", r.json()["filename"] as string);
        this.assertEquals(22, r.json()["size"] as int);
    }

    @test
    func uploadListedAfterUpload(): void {
        let body = multipartFile("file", "a.txt", "text/plain", "alpha");
        this.client.request("POST",
            "/tasks/" + this.taskId + "/attachments", body,
            multipartHeaders(this.adaToken)).assertStatus(200);

        let list = this.client.request("GET",
            "/tasks/" + this.taskId + "/attachments", null,
            bearer(this.adaToken));
        list.assertStatus(200);
        let items = list.json()["items"] as list<any>;
        this.assertEquals(1, items.length());
        this.assertEquals("a.txt",
            (items[0] as dict<string, any>)["filename"] as string);
    }

    @test
    func cannotUploadToAnotherUsersTask(): void {
        let body = multipartFile("file", "x.txt", "text/plain", "x");
        let r = this.client.request("POST",
            "/tasks/" + this.taskId + "/attachments", body,
            multipartHeaders(this.carlaToken));
        r.assertStatus(404);
    }

    @test
    func missingFilePartIs400(): void {
        /* multipart body with no `file` part - empty boundary. */
        let body = "------tasks-test-boundary\r\n"
            + "Content-Disposition: form-data; name=\"label\"\r\n\r\n"
            + "decoy\r\n"
            + "------tasks-test-boundary--\r\n";
        let r = this.client.request("POST",
            "/tasks/" + this.taskId + "/attachments", body,
            multipartHeaders(this.adaToken));
        r.assertStatus(400);
    }
}

class OpenApiTest extends test.Test {
    gebweb.TestClient client;

    func setUp(): void {
        this.client = gebweb.TestClient(tasksapp.build());
    }

    @test
    func openapiSpecAdvertisesAllRoutes(): void {
        let r = this.client.get("/openapi.json");
        r.assertStatus(200);
        let spec = json.parse(r.text()) as dict<string, any>;
        let paths = spec["paths"] as dict<string, any>;
        this.assertTrue(paths.contains("/login"));
        this.assertTrue(paths.contains("/tasks"));
        this.assertTrue(paths.contains("/tasks/{id}"));
        this.assertTrue(paths.contains("/tasks/{id}/attachments"));
        this.assertTrue(paths.contains("/admin/tasks"));
        this.assertTrue(paths.contains("/admin/tasks/{id}"));
    }
}